// The UI in MTUI! Interfaces with the backend to form the complete mtui app.

import {spawn} from 'node:child_process'
import {readFile, writeFile} from 'node:fs/promises'
import path from 'node:path'
import url from 'node:url'

import {orderBy} from 'natural-orderby'
import open from 'open'

import {Button, Form, ListScrollForm, TextInput} from 'tui-lib/ui/controls'
import {Dialog} from 'tui-lib/ui/dialogs'
import {Label, Pane, WrapLabel} from 'tui-lib/ui/presentation'
import {DisplayElement, FocusElement} from 'tui-lib/ui/primitives'

import * as ansi from 'tui-lib/util/ansi'
import telc from 'tui-lib/util/telchars'
import unic from 'tui-lib/util/unichars'

import {getAllCrawlersForArg} from './crawlers.js'
import {originalSymbol} from './socket.js'
import processSmartPlaylist from './smart-playlist.js'
import UndoManager from './undo-manager.js'

import {
  commandExists,
  getSecFromTimestamp,
  getTimeStringsFromSec,
  promisifyProcess,
  shuffleArray,
} from './general-util.js'

import {
  cloneGrouplike,
  collapseGrouplike,
  countTotalTracks,
  findItemObject,
  flattenGrouplike,
  getCorrespondingFileForItem,
  getCorrespondingPlayableForFile,
  getFlatTrackList,
  getFlatGroupList,
  getItemPath,
  getNameWithoutTrackNumber,
  isGroup,
  isOpenable,
  isPlayable,
  isTrack,
  parentSymbol,
  reverseOrderOfGroups,
  searchForItem,
  shuffleOrderOfGroups,
} from './playlist-utils.js'

import {
  updateRestoredTracksUsingPlaylists,
  getWaitingTrackData
} from './serialized-backend.js'

/* text editor features disabled because theyre very much incomplete and havent
 * gotten much use from me or anyone afaik!
const TuiTextEditor = require('tui-text-editor')
*/

const input = {}

const keyBindings = [
  ['isUp', telc.isUp],
  ['isDown', telc.isDown],
  ['isLeft', telc.isLeft],
  ['isRight', telc.isRight],
  ['isSelect', telc.isSelect],
  ['isBackspace', telc.isBackspace],
  ['isMenu', 'm'],
  ['isMenu', 'f'],
  ['isScrollToStart', 'g', {caseless: false}],
  ['isScrollToEnd', 'G', {caseless: false}],
  ['isScrollToStart', telc.isHome],
  ['isScrollToEnd', telc.isEnd],
  ['isTogglePause', telc.isSpace],
  ['isToggleLoop', 'l'],
  ['isStop', telc.isEscape],
  ['isVolumeUp', 'v', {caseless: false}],
  ['isVolumeDown', 'V', {caseless: false}],
  ['isSkipBack', telc.isControlUp],
  ['isSkipAhead', telc.isControlDown],
  ['isSkipBack', 'p'],
  ['isSkipAhead', 'n'],
  ['isFocusTabber', '['],
  ['isFocusQueue', ']'],
  ['isFocusPlaybackInfo', '|'],
  ['isNextTab', 't', {caseless: false}],
  ['isPreviousTab', 'T', {caseless: false}],
  ['isDownload', 'd'],
  ['isRemove', 'x'],
  ['isQueueAfterSelectedTrack', 'q'],
  ['isOpenThroughSystem', 'o'],
  ['isShuffleQueue', 's'],
  ['isClearQueue', 'c'],
  ['isFocusMenubar', ';'],
  // ['isFocusLabels', 'L', {caseless: false}], // todo: better key? to let isToggleLoop be caseless again
  ['isSelectUp', telc.isShiftUp],
  ['isSelectDown', telc.isShiftDown],
  ['isNextThemeColor', 'c', {caseless: false}],
  ['isPreviousThemeColor', 'C', {caseless: false}],

  ['isPreviousPlayer', telc.isMetaUp],
  ['isPreviousPlayer', [0x1b, 'p']],
  ['isNextPlayer', telc.isMetaDown],
  ['isNextPlayer', [0x1b, 'n']],
  ['isNewPlayer', [0x1b, 'c']],
  ['isRemovePlayer', [0x1b, 'x']],
  ['isActOnPlayer', [0x1b, 'a']],
  ['isActOnPlayer', [0x1b, '!']],

  ['isFocusTextEditor', [0x05]], // ^E
  ['isSaveTextEditor', [0x13]], // ^S
  ['isDeselectTextEditor', [0x18]], // ^X
  ['isDeselectTextEditor', telc.isEscape],

  // Number pad
  ['isUp', '8'],
  ['isDown', '2'],
  ['isLeft', '4'],
  ['isRight', '6'],
  ['isSpace', '5'],
  ['isTogglePause', '5'],
  ['isBackspace', '.'],
  ['isMenu', '+'],
  ['isMenu', '0'],
  ['isSkipBack', '1'],
  ['isSkipAhead', '3'],
  // Disabled because this is the jump key! Oops.
  // ['isVolumeDown', '/'],
  // ['isVolumeUp', '*'],
  ['isFocusTabber', '7'],
  ['isFocusQueue', '9'],
  ['isFocusMenubar', '*'],

  // HJKL
  ['isDown', 'j'],
  ['isUp', 'k'],
  // Don't use these for now... currently L is used for toggling loop.
  // May want to look into changing that (so we can re-enable these).
  // ['isLeft', 'h'],
  // ['isRight', 'l'],
]

const addKey = (prop, keyOrFunc, {caseless = true} = {}) => {
  const oldFunc = input[prop] || (() => false)
  let newFunc
  if (typeof keyOrFunc === 'function') {
    newFunc = keyOrFunc
  } else if (typeof keyOrFunc === 'string') {
    const key = keyOrFunc
    if (caseless) {
      newFunc = input => input.toString().toLowerCase() === key.toLowerCase()
    } else {
      newFunc = input => input.toString() === key
    }
  } else if (Array.isArray(keyOrFunc)) {
    const buf = Buffer.from(keyOrFunc.map(k => typeof k === 'string' ? k.charCodeAt(0) : k))
    newFunc = keyBuf => keyBuf.equals(buf)
  }
  input[prop] = keyBuf => newFunc(keyBuf) || oldFunc(keyBuf)
}

for (const entry of keyBindings) {
  addKey(...entry)
}

// Some things just need to be overridden in order for the rest of tui-lib to
// recognize our new keys.
telc.isUp = input.isUp
telc.isDown = input.isDown
telc.isLeft = input.isLeft
telc.isRight = input.isRight
telc.isSelect = input.isSelect
telc.isBackspace = input.isBackspace

export default class AppElement extends FocusElement {
  constructor(backend, config = {}) {
    super()

    this.backend = backend
    this.telnetServer = null
    this.isPartyHost = false
    this.enableAutoDJ = false

    // this.playlistSources = []

    this.config = Object.assign({
      canControlPlayback: true,
      canControlQueue: true,
      canControlQueuePlayers: true,
      canProcessMetadata: true,
      canSuspend: true,
      themeColor: 4, // blue
      seekToStartThreshold: 3,
      showTabberPane: true,
      stopPlayingUponQuit: true,
      showPartyControls: false
    }, config)

    // TODO: Move edit mode stuff to the backend!
    this.undoManager = new UndoManager()
    this.markGrouplike = {name: 'Selected Items', items: []}
    this.cachedMarkStatuses = new Map()
    this.editMode = false

    this.timestampDictionary = new WeakMap()

    // We add this is a child later (so that it's on top of every element).
    this.menuLayer = new DisplayElement()
    this.menuLayer.clickThrough = true
    this.showContextMenu = this.showContextMenu.bind(this)

    this.menubar = new Menubar(this.showContextMenu)
    this.addChild(this.menubar)

    this.setThemeColor(this.config.themeColor)
    this.menubar.on('color', color => this.setThemeColor(color))

    this.tabberPane = new Pane()
    this.addChild(this.tabberPane)

    this.queuePane = new Pane()
    this.addChild(this.queuePane)

    /*
    this.textInfoPane = new Pane()
    this.addChild(this.textInfoPane)

    this.textEditor = new NotesTextEditor()
    this.textInfoPane.addChild(this.textEditor)
    this.textInfoPane.visible = false

    this.textEditor.on('deselect', () => {
      this.root.select(this.tabber)
      this.fixLayout()
    })
    */

    this.logPane = new Pane()
    this.addChild(this.logPane)

    this.log = new Log()
    this.logPane.addChild(this.log)
    this.logPane.visible = false

    this.log.on('log message', () => {
      this.logPane.visible = true
      this.fixLayout()
    })

    if (!this.config.showTabberPane) {
      this.tabberPane.visible = false
    }

    this.tabber = new Tabber()
    this.tabberPane.addChild(this.tabber)

    this.metadataStatusLabel = new Label()
    this.metadataStatusLabel.visible = false
    this.tabberPane.addChild(this.metadataStatusLabel)

    this.queueListingElement = new QueueListingElement(this)
    this.setupCommonGrouplikeListingEvents(this.queueListingElement)
    this.queuePane.addChild(this.queueListingElement)

    this.queueLengthLabel = new Label('')
    this.queuePane.addChild(this.queueLengthLabel)

    this.queueTimeLabel = new Label('')
    this.queuePane.addChild(this.queueTimeLabel)

    this.queueListingElement.on('select', _item => this.updateQueueLengthLabel())
    this.queueListingElement.on('open', item => this.openSpecialOrThroughSystem(item))
    this.queueListingElement.on('queue', item => this.play(item))
    this.queueListingElement.on('remove', item => this.unqueue(item))
    this.queueListingElement.on('shuffle', () => this.shuffleQueue())
    this.queueListingElement.on('clear', () => this.clearQueue())
    this.queueListingElement.on('select main listing',
      () => this.selected())

    if (this.config.showPartyControls) {
      const sharedSourcesListing = this.newGrouplikeListing()
      sharedSourcesListing.loadGrouplike(this.backend.sharedSourcesGrouplike)
    }

    this.playbackPane = new Pane()
    this.addChild(this.playbackPane)

    this.playbackForm = new ListScrollForm()
    this.playbackPane.addChild(this.playbackForm)

    this.playbackInfoElements = []

    this.partyTop = new DisplayElement()
    this.partyBottom = new DisplayElement()
    this.addChild(this.partyTop)
    this.addChild(this.partyBottom)
    this.partyTop.visible = false
    this.partyBottom.visible = false

    this.partyTopBanner = new PartyBanner(1)
    this.partyBottomBanner = new PartyBanner(-1)
    this.partyTop.addChild(this.partyTopBanner)
    this.partyBottom.addChild(this.partyBottomBanner)

    this.partyLabel = new Label('')
    this.partyTop.addChild(this.partyLabel)

    // Dialogs

    this.openPlaylistDialog = new OpenPlaylistDialog()
    this.setupDialog(this.openPlaylistDialog)

    this.openPlaylistDialog.on('source selected', source => this.loadPlaylistOrSource(source))
    this.openPlaylistDialog.on('source selected (new tab)', source => this.loadPlaylistOrSource(source, true))

    this.alertDialog = new AlertDialog()
    this.setupDialog(this.alertDialog)

    // Should be placed on top of everything else!
    this.addChild(this.menuLayer)

    this.whereControl = new InlineListPickerElement('Where?', [
      {value: 'after-selected', label: 'After selected track'},
      {value: 'next', label: 'After current track'},
      {value: 'end', label: 'At end of queue'},
      {value: 'distribute-evenly', label: 'Distributed across queue evenly'},
      {value: 'distribute-randomly', label: 'Distributed across queue randomly'},
      {value: 'before-selected', label: 'Before selected track'}
    ], this.showContextMenu)

    this.orderControl = new InlineListPickerElement('Order?', [
      {value: 'shuffle', label: 'Shuffle all'},
      {value: 'shuffle-groups', label: 'Shuffle order of groups'},
      {value: 'reverse', label: 'Reverse all'},
      {value: 'reverse-groups', label: 'Reverse order of groups'},
      {value: 'alphabetic', label: 'Alphabetically'},
      {value: 'alphabetic-groups', label: 'Alphabetize order of groups'},
      {value: 'normal', label: 'In order'}
    ], this.showContextMenu)

    this.menubar.buildItems([
      {text: 'mtui', menuItems: [
        {label: 'mtui (perpetual development)'},
        {divider: true},
        {label: 'Quit', action: () => this.shutdown()},
        this.config.canSuspend && {label: 'Suspend', action: () => this.suspend()}
      ]},
      {text: 'Playback', menuFn: () => {
        const { playingTrack } = this.SQP
        const { items } = this.SQP.queueGrouplike
        const curIndex = items.indexOf(playingTrack)
        const next = (curIndex >= 0) && items[curIndex + 1]
        const previous = (curIndex >= 0) && items[curIndex - 1]

        return [
          {label: playingTrack ? `("${playingTrack.name}")` : '(No track playing.)'},
          {divider: true},
          {element: this.volumeSlider},
          {divider: true},
          playingTrack && {element: this.playingControl},
          playingTrack && {element: this.loopingControl},
          playingTrack && {element: this.pauseNextControl},
          {element: this.autoDJControl},
          {divider: true},
          previous && {label: `Previous (${previous.name})`, action: () => this.SQP.playPrevious(playingTrack)},
          next && {label: `Next (${next.name})`, action: () => this.SQP.playNext(playingTrack)},
          !next && this.SQP.queueEndMode === 'loop' &&
          {label: `Next (loop queue)`, action: () => this.SQP.playNext(playingTrack)},
          next && {label: '- Play later', action: () => this.playLater(next)}
        ]
      }},
      {text: 'Queue', menuFn: () => {
        const { items } = this.SQP.queueGrouplike
        const curIndex = items.indexOf(this.playingTrack)

        return [
          {label: `(Queue - ${curIndex >= 0 ? `${curIndex + 1}/` : ''}${items.length} items.)`},
          {divider: true},
          {element: this.loopModeControl},
          {divider: true},
          items.length && {label: 'Shuffle', action: () => this.shuffleQueue()},
          items.length && {label: 'Clear', action: () => this.clearQueue()}
        ]
      }},
      {text: 'Multi', menuFn: () => {
        const { queuePlayers } = this.backend
        return [
          {key: 'heading', label: `(Multi-players - ${queuePlayers.length})`},
          {divider: true},
          ...queuePlayers.map((queuePlayer, index) => {
            const PIE = new PlaybackInfoElement(queuePlayer, this)
            PIE.displayMode = 'collapsed'
            PIE.updateTrack()
            return {key: 'qp' + index, element: PIE}
          }),
          {divider: true},
          {key: 'add-new-player', label: `Add new player`, action: () => this.addQueuePlayer().then(() => 'reload')}
        ]
      }}
    ])

    this.playingControl = new ToggleControl('Pause?', {
      setValue: val => this.SQP.setPause(val),
      getValue: () => this.SQP.player.isPaused,
      getEnabled: () => this.config.canControlPlayback
    })

    this.loopingControl = new ToggleControl('Loop current track?', {
      setValue: val => this.SQP.setLoop(val),
      getValue: () => this.SQP.player.isLooping,
      getEnabled: () => this.config.canControlPlayback
    })

    this.loopModeControl = new InlineListPickerElement('Loop queue?', [
      {value: 'end', label: 'Don\'t loop'},
      {value: 'loop', label: 'Loop (same order)'},
      {value: 'shuffle', label: 'Loop (shuffle)'}
    ], {
      setValue: val => {
        if (this.SQP) {
          this.SQP.queueEndMode = val
        }
      },
      getValue: () => this.SQP && this.SQP.queueEndMode,
      showContextMenu: this.showContextMenu
    })

    this.pauseNextControl = new ToggleControl('Pause when this track ends?', {
      setValue: val => this.SQP.setPauseNextTrack(val),
      getValue: () => this.SQP.pauseNextTrack,
      getEnabled: () => this.config.canControlPlayback
    })

    this.loopQueueControl = new ToggleControl('Loop queue?', {
      setValue: val => this.SQP.setLoopQueueAtEnd(val),
      getValue: () => this.SQP.loopQueueAtEnd,
      getEnabled: () => this.config.canControlPlayback
    })

    this.volumeSlider = new SliderElement('Volume', {
      setValue: val => this.SQP.setVolume(val),
      getValue: () => this.SQP.player.volume,
      getEnabled: () => this.config.canControlPlayback
    })

    this.autoDJControl = new ToggleControl('Enable Auto-DJ?', {
      setValue: val => (this.enableAutoDJ = val),
      getValue: () => this.enableAutoDJ,
      getEnabled: () => this.config.canControlPlayback
    })

    this.bindListeners()
    this.initialAttachListeners()

    // Also handy to be bound to the app.
    this.showContextMenu = this.showContextMenu.bind(this)

    this.queuePlayersToActOn = []
    this.selectQueuePlayer(this.backend.queuePlayers[0])
  }

  bindListeners() {
    for (const key of [
      'handlePlayingDetails',
      'handleReceivedTimeData',
      'handleProcessMetadataProgress',
      'handleQueueUpdated',
      'handleAddedQueuePlayer',
      'handleRemovedQueuePlayer',
      'handleLogMessage',
      'handleGotSharedSources',
      'handleSharedSourcesUpdated',
      'handleSetLoopQueueAtEnd'
    ]) {
      this[key] = this[key].bind(this)
    }
  }

  initialAttachListeners() {
    this.attachBackendListeners()
    for (const queuePlayer of this.backend.queuePlayers) {
      this.attachQueuePlayerListenersAndUI(queuePlayer)
    }
  }

  removeListeners() {
    this.removeBackendListeners()
    for (const queuePlayer of this.backend.queuePlayers) {
      // Don't update the UI - removeListeners is only called just before the
      // AppElement is done being used.
      this.removeQueuePlayerListenersAndUI(queuePlayer, false)
    }
  }

  attachQueuePlayerListenersAndUI(queuePlayer) {
    const PIE = new PlaybackInfoElement(queuePlayer, this)
    this.playbackInfoElements.push(PIE)
    this.playbackForm.addInput(PIE)
    this.fixLayout()

    PIE.on('seek back', () => PIE.queuePlayer.seekBack(5))
    PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5))
    PIE.on('toggle pause', () => PIE.queuePlayer.togglePause())
  }

  removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) {
    if (updateUI) {
      const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
      if (PIE) {
        const PIEs = this.playbackInfoElements
        const oldIndex = PIEs.indexOf(PIE)
        if (this.playbackForm.curIndex > oldIndex) {
          this.playbackForm.curIndex--
        }
        PIEs.splice(oldIndex, 1)
        this.playbackForm.removeInput(PIE)
        if (this.SQP === queuePlayer) {
          const { queuePlayer } = PIEs[Math.min(oldIndex, PIEs.length - 1)]
          this.selectQueuePlayer(queuePlayer)
        }
        this.fixLayout()
      }
    }

    const index = this.queuePlayersToActOn.indexOf(queuePlayer)
    if (index >= 0) {
      this.queuePlayersToActOn.splice(index, 1)
    }

    queuePlayer.stopPlaying()
  }

  attachBackendListeners() {
    // Backend-specialized listeners
    this.backend.on('processMetadata progress', this.handleProcessMetadataProgress)
    this.backend.on('added queue player', this.handleAddedQueuePlayer)
    this.backend.on('removed queue player', this.handleRemovedQueuePlayer)
    this.backend.on('log message', this.handleLogMessage)
    this.backend.on('got shared sources', this.handleGotSharedSources)
    this.backend.on('shared sources updated', this.handleSharedSourcesUpdated)
    this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd)

    // Backend as queue player proxy listeners
    this.backend.on('QP: playing details', this.handlePlayingDetails)
    this.backend.on('QP: received time data', this.handleReceivedTimeData)
    this.backend.on('QP: queue updated', this.handleQueueUpdated)
  }

  removeBackendListeners() {
    // Backend-specialized listeners
    this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress)
    this.backend.removeListener('added queue player', this.handleAddedQueuePlayer)
    this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer)
    this.backend.removeListener('log message', this.handleLogMessage)
    this.backend.removeListener('got shared sources', this.handleGotSharedSources)
    this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated)
    this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd)

    // Backend as queue player proxy listeners
    this.backend.removeListener('QP: playing details', this.handlePlayingDetails)
    this.backend.removeListener('QP: received time data', this.handleReceivedTimeData)
    this.backend.removeListener('QP: queue updated', this.handleQueueUpdated)
  }

  handleAddedQueuePlayer(queuePlayer) {
    this.attachQueuePlayerListenersAndUI(queuePlayer)
  }

  handleRemovedQueuePlayer(queuePlayer) {
    this.removeQueuePlayerListenersAndUI(queuePlayer)
    if (this.menubar.contextMenu) {
      setTimeout(() => this.menubar.contextMenu.reload(), 0)
    }
  }

  handleLogMessage(messageInfo) {
    this.log.newLogMessage(messageInfo)
  }

  handleGotSharedSources(socketId, sharedSources) {
    for (const grouplikeListing of this.tabber.tabberElements) {
      if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) {
        grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false)
      }
    }
  }

  handleSharedSourcesUpdated(socketId, partyGrouplike) {
    for (const grouplikeListing of this.tabber.tabberElements) {
      if (grouplikeListing.grouplike === partyGrouplike) {
        grouplikeListing.loadGrouplike(partyGrouplike, false)
      }
    }
    this.clearCachedMarkStatuses()
  }

  handleSetLoopQueueAtEnd() {
    this.updateQueueLengthLabel()
  }

  async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) {
    const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
    if (PIE) {
      PIE.updateTrack()
    }

    if (queuePlayer === this.SQP) {
      this.updateQueueLengthLabel()
      this.queueListingElement.collapseTimestamps(oldTrack)
      if (track && this.queueListingElement.currentItem === oldTrack) {
        this.queueListingElement.selectAndShow(track)
      }
    }

    // Unfortunately, there isn't really any reliable way to make these work if
    // the containing queue isn't of the selected queue player.
    const timestampData = track && this.getTimestampData(track)
    if (timestampData && queuePlayer === this.SQP) {
      if (this.queueListingElement.currentItem === track) {
        this.queueListingElement.selectTimestampAtSec(track, startTime)
      }
    }

    if (track && this.enableAutoDJ) {
      queuePlayer.setVolumeMultiplier(0.5);
      const message = 'now playing: ' + getNameWithoutTrackNumber(track);
      if (await commandExists('espeak')) {
        await promisifyProcess(spawn('espeak', [message]));
      } else if (await commandExists('say')) {
        await promisifyProcess(spawn('say', [message]));
      }
      queuePlayer.fadeIn();
    }
  }

  handleReceivedTimeData(queuePlayer, timeData, oldTimeData) {
    const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
    if (PIE) {
      PIE.updateProgress()
    }

    if (queuePlayer === this.SQP) {
      this.updateQueueLengthLabel()
      this.updateQueueSelection(timeData, oldTimeData)
    }
  }

  handleProcessMetadataProgress(remaining) {
    this.metadataStatusLabel.text = `Processing metadata - ${remaining} to go.`
    this.updateQueueLengthLabel()
  }

  handleQueueUpdated() {
    this.queueListingElement.buildItems()
  }

  selectQueuePlayer(queuePlayer) {
    // You can use this.SQP as a shorthand to get this.
    this.selectedQueuePlayer = queuePlayer

    this.queueListingElement.loadGrouplike(queuePlayer.queueGrouplike)

    this.playbackForm.curIndex = this.playbackForm.inputs
      .findIndex(el => el.queuePlayer === queuePlayer)
    this.playbackForm.scrollSelectedElementIntoView()
  }

  selectNextQueuePlayer() {
    const { queuePlayers } = this.backend
    let index = queuePlayers.indexOf(this.SQP) + 1
    if (index >= queuePlayers.length) {
      index = 0
    }
    this.selectQueuePlayer(queuePlayers[index])
  }

  selectPreviousQueuePlayer() {
    const { queuePlayers } = this.backend
    let index = queuePlayers.indexOf(this.SQP) - 1
    if (index <= -1) {
      index = queuePlayers.length - 1
    }
    this.selectQueuePlayer(queuePlayers[index])
  }

  async addQueuePlayer() {
    if (!this.config.canControlQueuePlayers) {
      return false
    }

    const queuePlayer = await this.backend.addQueuePlayer()
    this.selectQueuePlayer(queuePlayer)
  }

  removeQueuePlayer(queuePlayer) {
    if (!this.config.canControlQueuePlayers) {
      return false
    }

    this.backend.removeQueuePlayer(queuePlayer)
  }

  toggleActOnQueuePlayer(queuePlayer) {
    const index = this.queuePlayersToActOn.indexOf(queuePlayer)
    if (index >= 0) {
      this.queuePlayersToActOn.splice(index, 1)
    } else {
      this.queuePlayersToActOn.push(queuePlayer)
    }

    for (const PIE of this.playbackInfoElements) {
      PIE.fixLayout()
    }
  }

  getPlaybackInfoElementForQueuePlayer(queuePlayer) {
    return this.playbackInfoElements
      .find(el => el.queuePlayer === queuePlayer)
  }

  selected() {
    if (this.tabberPane.visible) {
      this.root.select(this.tabber)
    } else {
      if (this.queueListingElement.selectable) {
        this.root.select(this.queueListingElement)
      } else {
        this.menubar.select()
      }
    }
  }

  newGrouplikeListing() {
    const grouplikeListing = new GrouplikeListingElement(this)
    this.tabber.addTab(grouplikeListing)
    this.tabber.selectTab(grouplikeListing)

    grouplikeListing.on('browse', item => this.browse(grouplikeListing, item))
    grouplikeListing.on('download', item => this.SQP.download(item))
    grouplikeListing.on('open', item => this.openSpecialOrThroughSystem(item))
    grouplikeListing.on('queue', (item, opts) => this.handleQueueOptions(item, opts))

    const updateListingsFor = item => {
      for (const grouplikeListing of this.tabber.tabberElements) {
        if (grouplikeListing.grouplike === item) {
          this.browse(grouplikeListing, item, false)
        }
      }
    }

    grouplikeListing.on('remove', item => {
      if (this.editMode) {
        const parent = item[parentSymbol]
        const index = parent.items.indexOf(item)

        this.undoManager.pushAction({
          activate: () => {
            parent.items.splice(index, 1)
            delete item[parentSymbol]
            updateListingsFor(item)
            updateListingsFor(parent)
          },
          undo: () => {
            parent.items.splice(index, 0, item)
            item[parentSymbol] = parent
            updateListingsFor(item)
            updateListingsFor(parent)
          }
        })
      }
    })

    grouplikeListing.on('mark', item => {
      if (this.editMode) {
        if (!this.markGrouplike.items.includes(item)) {
          this.undoManager.pushAction({
            activate: () => {
              this.markGrouplike.items.push(item)
            },
            undo: () => {
              this.markGrouplike.items.pop()
            }
          })
        } else {
          const index = this.markGrouplike.items.indexOf(item)
          this.undoManager.pushAction({
            activate: () => {
              this.markGrouplike.items.splice(index, 1)
            },
            undo: () => {
              this.markGrouplike.items.splice(index, 0, item)
            }
          })
        }
      }
    })

    grouplikeListing.on('paste', (item, {where = 'below'} = {}) => {
      if (this.editMode && this.markGrouplike.items.length) {
        let parent, index

        if (where === 'above') {
          parent = item[parentSymbol]
          index = parent.items.indexOf(item)
        } else if (where === 'below') {
          parent = item[parentSymbol]
          index = parent.items.indexOf(item) + 1
        }

        this.undoManager.pushAction({
          activate: () => {
            parent.items.splice(index, 0, ...cloneGrouplike(this.markGrouplike).items.map(
              item => Object.assign({}, item, {[parentSymbol]: parent})
            ))
            updateListingsFor(parent)
          },
          undo: () => {
            parent.items.splice(index, this.markGrouplike.items.length)
            updateListingsFor(parent)
          }
        })
      }
    })

    this.setupCommonGrouplikeListingEvents(grouplikeListing)

    return grouplikeListing
  }

  setupCommonGrouplikeListingEvents(grouplikeListing) {
    // Sets up event listeners that are common to ordinary grouplike listings
    // (made by newGrouplikeListing) as well as the queue grouplike listing.

    grouplikeListing.on('timestamp', (item, time) => this.playOrSeek(item, time))
    grouplikeListing.pathElement.on('select', (item, child) => this.revealInLibrary(item, child))
    grouplikeListing.on('menu', (item, el) => this.showMenuForItemElement(el, grouplikeListing))
    /*
    grouplikeListing.on('select', item => this.editNotesFile(item, false))
    grouplikeListing.on('edit-notes', item => {
      this.revealInLibrary(item)
      this.editNotesFile(item, true)
    })
    */
  }

  showContextMenu(opts) {
    const menu = new ContextMenu(this.showContextMenu)
    this.menuLayer.addChild(menu)
    if (opts.beforeShowing) {
      opts.beforeShowing(menu)
    }
    menu.show(opts)
    return menu
  }

  browse(grouplikeListing, grouplike, ...args) {
    this.loadTimestampDataInGrouplike(grouplike)
    grouplikeListing.loadGrouplike(grouplike, ...args)
  }

  revealInLibrary(item, child) {
    if (!this.tabberPane.visible) {
      return
    }

    const tabberListing = this.tabber.currentElement
    this.root.select(tabberListing)

    const parent = item[parentSymbol]
    if (isGroup(item)) {
      tabberListing.loadGrouplike(item)
      if (child) {
        tabberListing.selectAndShow(child)
      }
    } else if (parent) {
      if (tabberListing.grouplike !== parent) {
        tabberListing.loadGrouplike(parent)
      }
      tabberListing.selectAndShow(item)
    }
  }

  revealInQueue(item) {
    const queueListing = this.queueListingElement
    if (queueListing.selectAndShow(item)) {
      this.root.select(queueListing)
    }
  }

  play(item) {
    if (!this.config.canControlQueue) {
      return
    }

    this.SQP.play(item)
  }

  playOrSeek(item, time) {
    if (!this.config.canControlQueue || !this.config.canControlPlayback) {
      return
    }

    this.SQP.playOrSeek(item, time)
  }

  unqueue(item) {
    if (!this.config.canControlQueue) {
      return
    }

    let focusItem = this.queueListingElement.currentItem
    focusItem = this.SQP.unqueue(item, focusItem)

    this.queueListingElement.buildItems()
    this.updateQueueLengthLabel()

    if (focusItem) {
      this.queueListingElement.selectAndShow(focusItem)
    }
  }

  playSooner(item) {
    if (!this.config.canControlQueue) {
      return
    }

    this.SQP.playSooner(item)
    // It may not have queued as soon as the user wants; in that case, they'll
    // want to queue it sooner again. Automatically reselect the track so that
    // this they don't have to navigate back to it by hand.
    this.queueListingElement.selectAndShow(item)
  }

  playLater(item) {
    if (!this.config.canControlQueue) {
      return
    }

    this.SQP.playLater(item)
    // Just for consistency with playSooner (you can press ^-L to quickly get
    // back to the current track).
    this.queueListingElement.selectAndShow(item)
  }

  clearQueuePast(item) {
    if (!this.config.canControlQueue) {
      return
    }

    this.SQP.clearQueuePast(item)
    this.queueListingElement.selectAndShow(item)
  }

  clearQueueUpTo(item) {
    if (!this.config.canControlQueue) {
      return
    }

    this.SQP.clearQueueUpTo(item)
    this.queueListingElement.selectAndShow(item)
  }

  shareWithParty(item) {
    this.backend.shareWithParty(item)
  }

  replaceMark(items) {
    this.markGrouplike.items = items.slice(0) // Don't share the array! :)
    this.emitMarkChanged()
  }

  unmarkAll() {
    this.markGrouplike.items = []
    this.emitMarkChanged()
  }

  markItem(item) {
    if (isGroup(item)) {
      for (const child of item.items) {
        this.markItem(child)
      }
    } else {
      const { items } = this.markGrouplike
      if (!items.includes(item)) {
        items.push(item)
        this.emitMarkChanged()
      }
    }
  }

  unmarkItem(item) {
    if (isGroup(item)) {
      for (const child of item.items) {
        this.unmarkItem(child)
      }
    } else {
      const { items } = this.markGrouplike
      if (items.includes(item)) {
        items.splice(items.indexOf(item), 1)
        this.emitMarkChanged()
      }
    }
  }

  getMarkStatus(item) {
    if (!this.cachedMarkStatuses.get(item)) {
      const { items } = this.markGrouplike
      let status
      if (isGroup(item)) {
        const tracks = flattenGrouplike(item).items
        if (tracks.every(track => items.includes(track))) {
          status = 'marked'
        } else if (tracks.some(track => items.includes(track))) {
          status = 'partial'
        } else {
          status = 'unmarked'
        }
      } else {
        if (items.includes(item)) {
          status = 'marked'
        } else {
          status = 'unmarked'
        }
      }
      this.cachedMarkStatuses.set(item, status)
    }
    return this.cachedMarkStatuses.get(item)
  }

  emitMarkChanged() {
    this.clearCachedMarkStatuses()
    this.emit('mark changed')
  }

  clearCachedMarkStatuses() {
    this.cachedMarkStatuses = new Map()
    this.scheduleDrawWithoutPropertyChange()
  }

  pauseAll() {
    if (!this.config.canControlPlayback) {
      return
    }

    for (const queuePlayer of this.backend.queuePlayers) {
      queuePlayer.setPause(true)
    }
  }

  resumeAll() {
    if (!this.config.canControlPlayback) {
      return
    }

    for (const queuePlayer of this.backend.queuePlayers) {
      queuePlayer.setPause(false)
    }
  }

  async createNotesFile(item) {
    if (!item[parentSymbol]) {
      return
    }

    if (!item.url) {
      return
    }

    if (getCorrespondingFileForItem(item, '.txt')) {
      return
    }

    let itemPath
    try {
      itemPath = url.fileURLToPath(item.url)
    } catch (error) {
      return
    }

    const dirname = path.dirname(itemPath)
    const extname = path.extname(itemPath)
    const basename = path.basename(itemPath, extname)
    const name = basename + '.txt'
    const filePath = path.join(dirname, name)
    const fileURL = url.pathToFileURL(filePath).toString()
    const file = {name, url: fileURL}
    await writeFile(filePath, '\n')

    const { items } = item[parentSymbol]
    items.splice(items.indexOf(item), 0, file)
  }

  /*
  async editNotesFile(item, focus) {
    if (!item) {
      return
    }

    // Creates it, if it doesn't exist.
    // We only do this when we're manually selecting the file (and expect to
    // focus it). Otherwise we'd create a notes file for every track hovered
    // over!
    if (focus) {
      await this.createNotesFile(item)
    }

    const doubleCheckItem = () => {
      const listing = this.root.selectedElement.directAncestors.find(el => el instanceof GrouplikeListingElement)
      return listing && listing.currentItem === item
    }

    if (!doubleCheckItem()) {
      return
    }

    const status = await this.textEditor.openItem(item, {doubleCheckItem})

    let fixLayout
    if (status === true) {
      this.textInfoPane.visible = true
      fixLayout = true
    } else if (status === false) {
      this.textInfoPane.visible = false
      fixLayout = true
    }

    if (focus && (status === true || status === null) && doubleCheckItem()) {
      this.root.select(this.textEditor)
      fixLayout = true
    }

    if (fixLayout) {
      this.fixLayout()
    }
  }
  */

  expandTimestamps(item, listing) {
    listing.expandTimestamps(item)
  }

  collapseTimestamps(item, listing) {
    listing.collapseTimestamps(item)
  }

  toggleTimestamps(item, listing) {
    listing.toggleTimestamps(item)
  }

  timestampsExpanded(item, listing) {
    return listing.timestampsExpanded(item)
  }

  hasTimestampsFile(item) {
    return !!this.getTimestampsFile(item)
  }

  getTimestampsFile(item) {
    // Only tracks have timestamp files!
    if (!isTrack(item)) {
      return false
    }

    return getCorrespondingFileForItem(item, '.timestamps.txt')
  }

  async loadTimestampDataInGrouplike(grouplike) {
    // Only load data for a grouplike once.
    if (this.timestampDictionary.has(grouplike)) {
      return
    }

    this.timestampDictionary.set(grouplike, true)

    // There's no parallelization here, but like, whateeeever.
    for (const item of grouplike.items) {
      if (!isTrack(item)) {
        continue
      }

      if (this.timestampDictionary.has(item)) {
        continue
      }

      if (!this.hasTimestampsFile(item)) {
        this.timestampDictionary.set(item, false)
        continue
      }

      this.timestampDictionary.set(item, null)
      const data = await this.readTimestampData(item)
      this.timestampDictionary.set(item, data)
    }
  }

  getTimestampData(item) {
    return this.timestampDictionary.get(item) || null
  }

  getTimestampAtSec(item, sec) {
    const timestampData = this.getTimestampData(item)
    if (!timestampData) {
      return null
    }

    // Just like, start from the end, man.
    // Why doesn't JavaScript have a findIndexFromEnd function???
    for (let i = timestampData.length - 1; i >= 0; i--) {
      const ts = timestampData[i];
      if (
        ts.timestamp <= sec &&
        ts.timestampEnd >= sec
      ) {
        return ts
      }
    }

    return null
  }

  async readTimestampData(item) {
    const file = this.getTimestampsFile(item)

    if (!file) {
      return null
    }

    let filePath
    try {
      filePath = url.fileURLToPath(new URL(file.url))
    } catch (error) {
      return null
    }

    let contents
    try {
      contents = (await readFile(filePath)).toString()
    } catch (error) {
      return null
    }

    if (contents.startsWith('[')) {
      try {
        return JSON.parse(contents)
      } catch (error) {
        return null
      }
    }

    const lines = contents.split('\n')
      .filter(line => !line.startsWith('#'))
      .filter(line => line)

    const metadata = this.backend.getMetadataFor(item)
    const duration = (metadata ? metadata.duration : Infinity)

    const data = lines
      .map(line => line.match(/^\s*([0-9:.]+)\s*(\S.*)\s*$/))
      .filter(match => match)
      .map(match => ({timestamp: getSecFromTimestamp(match[1]), comment: match[2]}))
      .filter(({ timestamp: sec }) => !isNaN(sec))
      .map((cur, i, arr) =>
        (i + 1 === arr.length
          ? {...cur, timestampEnd: duration}
          : {...cur, timestampEnd: arr[i + 1].timestamp}))

    return data
  }

  openSpecialOrThroughSystem(item) {
    if (item.url.endsWith('.json')) {
      return this.loadPlaylistOrSource(item.url, true)
      /*
    } else if (item.url.endsWith('.txt')) {
      if (this.textInfoPane.visible) {
        this.root.select(this.textEditor)
      }
      */
    } else {
      return this.openThroughSystem(item)
    }
  }

  openThroughSystem(item) {
    if (!isOpenable(item)) {
      return
    }

    open(item.url)
  }

  set actOnAllPlayers(val) {
    if (val) {
      this.queuePlayersToActOn = this.backend.queuePlayers.slice()
    } else {
      this.queuePlayersToActOn = []
    }
  }

  get actOnAllPlayers() {
    return this.queuePlayersToActOn.length === this.backend.queuePlayers.length
  }

  willActOnQueuePlayer(queuePlayer) {
    if (this.queuePlayersToActOn.length) {
      if (this.queuePlayersToActOn.includes(queuePlayer)) {
        return 'marked'
      }
    } else if (queuePlayer === this.SQP) {
      return '=SQP'
    }
  }

  skipBackOrSeekToStart() {
    // Perform the same action - skipping to the previous track or seeking to
    // the start of the current track - for all target queue players. If any is
    // past an arbitrary time position (default 3 seconds), seek to start; if
    // all are before this position, skip to previous.

    let maxCurSec = 0
    this.forEachQueuePlayerToActOn(qp => {
      if (qp.timeData) {
        let effectiveCurSec = qp.timeData.curSecTotal

        const ts = (qp.timeData &&
          this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))

        if (ts) {
          effectiveCurSec -= ts.timestamp
        }

        maxCurSec = Math.max(maxCurSec, effectiveCurSec)
      }
    })

    if (Math.floor(maxCurSec) < this.config.seekToStartThreshold) {
      this.skipBack()
    } else {
      this.seekToStart()
    }
  }

  seekToStart() {
    this.actOnQueuePlayers(qp => qp.seekToStart())
    this.actOnQueuePlayers(qp => {
      if (!qp.playingTrack) {
        return
      }

      const ts = (qp.timeData &&
        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))

      if (ts) {
        qp.seekTo(ts.timestamp)
        return
      }

      qp.seekToStart()
    })
  }

  skipBack() {
    this.actOnQueuePlayers(qp => {
      if (!qp.playingTrack) {
        return
      }

      const ts = (qp.timeData &&
        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))

      if (ts) {
        const timestampData = this.getTimestampData(qp.playingTrack)
        const playingTimestampIndex = timestampData.indexOf(ts)
        const previous = timestampData[playingTimestampIndex - 1]
        if (previous) {
          qp.seekTo(previous.timestamp)
          return
        }
      }

      qp.playPrevious(qp.playingTrack, true)
    })
  }

  skipAhead() {
    this.actOnQueuePlayers(qp => {
      if (!qp.playingTrack) {
        return
      }

      const ts = (qp.timeData &&
        this.getTimestampAtSec(qp.playingTrack, qp.timeData.curSecTotal))

      if (ts) {
        const timestampData = this.getTimestampData(qp.playingTrack)
        const playingTimestampIndex = timestampData.indexOf(ts)
        const next = timestampData[playingTimestampIndex + 1]
        if (next) {
          qp.seekTo(next.timestamp)
          return
        }
      }

      qp.playNext(qp.playingTrack, true)
    })
  }

  actOnQueuePlayers(fn) {
    this.forEachQueuePlayerToActOn(queuePlayer => {
      fn(queuePlayer)
      const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer)
      if (PIE) {
        PIE.updateProgress()
      }
    })
  }

  forEachQueuePlayerToActOn(fn) {
    const actOn = this.queuePlayersToActOn.length ? this.queuePlayersToActOn : [this.SQP]
    actOn.forEach(fn)
  }

  showMenuForItemElement(el, listing) {
    // const { editMode } = this
    const { canControlQueue, canProcessMetadata } = this.config
    // const anyMarked = editMode && this.markGrouplike.items.length > 0

    const generatePageForItem = item => {
      const emitControls = play => () => {
        this.handleQueueOptions(item, {
          where: this.whereControl.curValue,
          order: this.orderControl.curValue,
          play: play
        })
      }

      const itemPath = getItemPath(item)
      const [rootGroup, _partySources, sharedGroup] = itemPath

      // This is the hack mentioned in the todo!!!!
      if (this.config.showPartyControls && rootGroup.isPartySources) {
        const playlists = this.tabber.tabberElements
          .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0])
          .filter(root => !root.isPartySources)

        let possibleChoices = []
        if (item.downloaderArg) {
          possibleChoices = getFlatTrackList({items: playlists})
        } else if (item.items) {
          possibleChoices = getFlatGroupList({items: playlists})
        }
        if (possibleChoices) {
          item = findItemObject(item, possibleChoices)
        }

        if (!item) {
          return [
            {label: `(Couldn't find this in your music)`}
          ]
        }
      }

      // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt')
      const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing)
        ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)}
        : {label: 'Expand saved timestamps', action: () => this.expandTimestamps(item, listing)}
      )
      const isQueued = this.SQP.queueGrouplike.items.includes(item)

      if (listing.grouplike.isTheQueue && isTrack(item)) {
        return [
          item[parentSymbol] && this.tabberPane.visible && {label: 'Reveal in library', action: () => this.revealInLibrary(item)},
          timestampsItem,
          {divider: true},
          canControlQueue && {label: 'Play later', action: () => this.playLater(item)},
          canControlQueue && {label: 'Play sooner', action: () => this.playSooner(item)},
          {divider: true},
          canControlQueue && {label: 'Clear past this track', action: () => this.clearQueuePast(item)},
          canControlQueue && {label: 'Clear up to this track', action: () => this.clearQueueUpTo(item)},
          {divider: true},
          {label: 'Autoscroll', action: () => listing.toggleAutoscroll()},
          {divider: true},
          canControlQueue && {label: 'Remove from queue', action: () => this.unqueue(item)}
        ]
      } else {
        const numTracks = countTotalTracks(item)
        const { string: durationString } = this.backend.getDuration(item)
        return [
          // A label that just shows some brief information about the item.
          {label:
            `(${item.name ? `"${item.name}"` : 'Unnamed'} - ` +
            (isGroup(item) ? ` ${numTracks} track${numTracks === 1 ? '' : 's'}, ` : '') +
            durationString +
            ')',
            keyboardIdentifier: item.name,
            isPageSwitcher: true
          },

          // The actual controls!
          {divider: true},

          // TODO: Don't emit these on the element (and hence receive them from
          // the listing) - instead, handle their behavior directly. We'll want
          // to move the "mark"/"paste" (etc) code into separate functions,
          // instead of just defining their behavior inside the listing event
          // handlers.

          // editMode && {label: isMarked ? 'Unmark' : 'Mark', action: () => el.emit('mark')},
          // anyMarked && {label: 'Paste (above)', action: () => el.emit('paste', {where: 'above'})},
          // anyMarked && {label: 'Paste (below)', action: () => el.emit('paste', {where: 'below'})},
          // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group)
          // {divider: true},

          ...((this.config.showPartyControls && !rootGroup.isPartySources)
            ? [
              {label: 'Share with party', action: () => this.shareWithParty(item)},
              {divider: true}
            ]
            : [
              canControlQueue && isPlayable(item) && {element: this.whereControl},
              canControlQueue && isGroup(item) && {element: this.orderControl},
              canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)},
              canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)},
              {divider: true},
              canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))},
              canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))},
              canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))},
              isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)},
              isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)},
              // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)},
              // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)},
              canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)},
              isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)},
              {divider: true},

              timestampsItem,
              ...(item === this.markGrouplike
                ? [{label: 'Deselect all', action: () => this.unmarkAll()}]
                : [
                  this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)},
                  this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)}
                ])
            ])
        ]
      }
    }

    const pages = [
      this.markGrouplike.items.length && generatePageForItem(this.markGrouplike),
      el.item && generatePageForItem(el.item)
    ].filter(Boolean)

    // TODO: Implement this! :P
    // const isMarked = false

    this.showContextMenu({
      x: el.absLeft,
      y: el.absTop + 1,
      pages
    })
  }

  async loadPlaylistOrSource(sourceOrPlaylist, newTab = false) {
    if (this.openPlaylistDialog.visible) {
      this.openPlaylistDialog.close()
    }

    this.alertDialog.showMessage('Opening playlist...', false)

    let grouplike
    if (typeof sourceOrPlaylist === 'object' && isGroup(sourceOrPlaylist) || sourceOrPlaylist.source) {
      grouplike = sourceOrPlaylist
    } else {
      try {
        grouplike = await this.openPlaylist(sourceOrPlaylist)
      } catch (error) {
        if (error === 'unknown argument') {
          this.alertDialog.showMessage('Could not figure out how to load a playlist from: ' + sourceOrPlaylist)
        } else if (typeof error === 'string') {
          this.alertDialog.showMessage(error)
        } else {
          throw error
        }

        return
      }
    }

    this.alertDialog.close()

    grouplike = await processSmartPlaylist(grouplike)

    // this.playlistSources.push(grouplike)
    // updateRestoredTracksUsingPlaylists(this.backend, this.playlistSources)

    if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) {
      const grouplikeListing = this.newGrouplikeListing()
      grouplikeListing.loadGrouplike(grouplike)
    } else {
      this.tabber.currentElement.loadGrouplike(grouplike)
    }
  }

  openPlaylist(arg) {
    const crawlers = getAllCrawlersForArg(arg)

    if (crawlers.length === 0) {
      throw 'unknown argument'
    }

    const crawler = crawlers[0]

    return crawler(arg)
  }

  setupDialog(dialog) {
    dialog.visible = false
    this.addChild(dialog)

    dialog.on('cancelled', () => {
      dialog.close()
    })
  }

  async shutdown() {
    if (this.config.stopPlayingUponQuit) {
      await this.backend.stopPlayingAll()
    }

    /*
    await this.textEditor.save()
    */
    this.emit('quitRequested')
  }

  suspend() {
    if (this.config.canSuspend) {
      this.emit('suspendRequested')
    }
  }

  fixLayout() {
    if (this.parent) {
      this.fillParent()
    }

    this.menubar.fixLayout()

    let topY = this.contentH

    if (this.partyBottom.visible) {
      this.partyBottom.w = this.contentW
      this.partyBottom.h = 1
      this.partyBottom.x = 0
      this.partyBottom.y = topY - this.partyBottom.h
      topY = this.partyBottom.top
      this.partyBottomBanner.w = this.partyBottom.w
    }

    this.playbackPane.w = this.contentW
    this.playbackPane.h = 5
    this.playbackPane.x = 0
    this.playbackPane.y = topY - this.playbackPane.h
    topY = this.playbackPane.top

    for (const PIE of this.playbackInfoElements) {
      if (this.playbackInfoElements.length === 1) {
        PIE.displayMode = 'expanded'
      } else {
        PIE.displayMode = 'collapsed'
      }
    }
    this.playbackForm.fillParent()
    this.playbackForm.fixLayout()

    let bottomY = 1

    if (this.partyTop.visible) {
      this.partyTop.w = this.contentW
      this.partyTop.h = 1
      this.partyTop.x = 0
      this.partyTop.y = 1
      bottomY = this.partyTop.bottom

      this.partyTopBanner.w = this.partyTop.w
      this.partyTopBanner.y = this.partyTop.contentH - 1

      this.alignPartyLabel()
    }

    const leftWidth = Math.max(Math.floor(0.7 * this.contentW), this.contentW - 80)

    /*
    if (this.textInfoPane.visible) {
      this.textInfoPane.w = leftWidth
      if (this.textEditor.isSelected) {
        this.textInfoPane.h = 8
      } else {
        this.textEditor.w = this.textInfoPane.contentW
        this.textEditor.rebuildUiLines()
        this.textInfoPane.h = Math.min(8, this.textEditor.getOptimalHeight() + 2)
      }

      this.textEditor.fillParent()
      this.textEditor.fixLayout()
    }
    */

    if (this.logPane.visible) {
      this.logPane.w = leftWidth
      this.logPane.h = 6
      this.log.fillParent()
      this.log.fixAllLayout()
    }

    if (this.tabberPane.visible) {
      this.tabberPane.w = leftWidth
      this.tabberPane.y = bottomY
      this.tabberPane.h = topY - this.tabberPane.y
      if (this.logPane.visible) {
        this.tabberPane.h -= this.logPane.h
        this.logPane.y = this.tabberPane.bottom
      }
      /*
      if (this.textInfoPane.visible) {
        this.tabberPane.h -= this.textInfoPane.h
        this.textInfoPane.y = this.tabberPane.bottom
      }
      */
      this.queuePane.x = this.tabberPane.right
      this.queuePane.w = this.contentW - this.tabberPane.right
    } else {
      this.queuePane.x = 0
      this.queuePane.w = this.contentW
      /*
      if (this.textInfoPane.visible) {
        this.textInfoPane.y = bottomY
      }
      */
    }

    this.queuePane.y = bottomY
    this.queuePane.h = topY - this.queuePane.y
    topY = this.queuePane.y

    this.tabber.fillParent()

    if (this.metadataStatusLabel.visible) {
      this.tabber.h--
      this.metadataStatusLabel.y = this.tabberPane.contentH - 1
    }

    this.tabber.fixLayout()

    this.queueListingElement.fillParent()
    this.queueListingElement.h -= 2

    this.updateQueueLengthLabel()

    this.menuLayer.fillParent()
  }

  alignPartyLabel() {
    this.partyLabel.centerInParent()
    this.partyLabel.y = 0
  }

  attachAsServerHost(telnetServer) {
    this.isPartyHost = true
    this.attachAsServer(telnetServer)
  }

  attachAsServerClient(telnetServer) {
    this.isPartyHost = false
    this.attachAsServer(telnetServer)
  }

  attachAsServer(telnetServer) {
    this.telnetServer = telnetServer
    this.updatePartyLabel()

    this.telnetServer.on('joined', () => this.updatePartyLabel())
    this.telnetServer.on('left', () => this.updatePartyLabel())

    this.partyTop.visible = true
    this.partyBottom.visible = true
    this.fixLayout()
  }

  updatePartyLabel() {
    const clients = this.telnetServer.sockets.length
    const clientsMsg = clients === 1 ? '1-ish connection' : `${clients}-ish connections`
    let msg = `${process.env.USER} playing for ${clientsMsg}`

    this.partyLabel.text = `  ${msg}  `
    this.alignPartyLabel()
  }

  keyPressed(keyBuf) {
    if (keyBuf[0] === 0x03) { // Ctrl-C
      this.shutdown()
      return
    } else if (keyBuf[0] === 0x1a) { // Ctrl-Z
      this.suspend()
      return
    }

    if ((telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) && this.menubar.isSelected) {
      this.menubar.restoreSelection()
      return
    }

    if (this.config.canControlPlayback) {
      if ((telc.isLeft(keyBuf) || telc.isRight(keyBuf)) && this.menubar.isSelected) {
        return // le sigh
      } else if (input.isRight(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.seekAhead(10))
      } else if (input.isLeft(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.seekBack(10))
      } else if (input.isTogglePause(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.togglePause())
      } else if (input.isToggleLoop(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.toggleLoop())
      } else if (input.isVolumeUp(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.volUp())
      } else if (input.isVolumeDown(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.volDown())
      } else if (input.isStop(keyBuf)) {
        this.actOnQueuePlayers(qp => qp.stopPlaying())
      } else if (input.isSkipBack(keyBuf)) {
        this.skipBackOrSeekToStart()
      } else if (input.isSkipAhead(keyBuf)) {
        this.skipAhead()
      }
    }

    if (input.isFocusTabber(keyBuf) && this.tabberPane.visible && this.tabber.selectable) {
      this.root.select(this.tabber)
    } else if (input.isFocusQueue(keyBuf) && this.queueListingElement.selectable) {
      this.root.select(this.queueListingElement)
    } else if (input.isFocusPlaybackInfo(keyBuf) && this.backend.queuePlayers.length > 1) {
      this.root.select(this.playbackForm)
    } else if (input.isFocusMenubar(keyBuf)) {
      if (this.menubar.isSelected) {
        this.menubar.restoreSelection()
      } else {
        // If we've got a menu open, close it, restoring selection to the
        // element selected before the menu was opened, so the menubar will
        // see that as the previously selected element (instead of the context
        // menu - which will be closed irregardless and gone when the menubar
        // tries to restore the selection).
        if (this.menuLayer.children[0]) {
          this.menuLayer.children[0].close()
        }
        this.menubar.select()
      }
    } else if (this.editMode && keyBuf.equals(Buffer.from([14]))) { // ctrl-N
      this.newEmptyTab()
    } else if (keyBuf.equals(Buffer.from([15]))) { // ctrl-O
      this.openPlaylistDialog.open()
    } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from([20]))) { // ctrl-T
      this.cloneCurrentTab()
    } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from([23]))) { // ctrl-W
      if (this.tabber.tabberElements.length > 1) {
        this.closeCurrentTab()
      }
    } else if (telc.isCharacter(keyBuf, 'u')) {
      this.undoManager.undoLastAction()
    } else if (telc.isCharacter(keyBuf, 'U')) {
      this.undoManager.redoLastUndoneAction()
    } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['t'.charCodeAt(0)]))) {
      this.tabber.nextTab()
    } else if (this.tabber.isSelected && keyBuf.equals(Buffer.from(['T'.charCodeAt(0)]))) {
      this.tabber.previousTab()
    } else if (input.isPreviousPlayer(keyBuf)) {
      this.selectPreviousQueuePlayer()
    } else if (input.isNextPlayer(keyBuf)) {
      this.selectNextQueuePlayer()
    } else if (input.isNewPlayer(keyBuf)) {
      this.addQueuePlayer()
    } else if (input.isRemovePlayer(keyBuf)) {
      this.removeQueuePlayer(this.SQP)
    } else if (input.isActOnPlayer(keyBuf)) {
      this.toggleActOnQueuePlayer(this.SQP)
    } else {
      super.keyPressed(keyBuf)
    }
  }

  newEmptyTab() {
    const listing = this.newGrouplikeListing()
    listing.loadGrouplike({
      name: 'New Playlist',
      items: []
    })
  }

  cloneCurrentTab() {
    const grouplike = this.tabber.currentElement.grouplike
    const listing = this.newGrouplikeListing()
    listing.loadGrouplike(grouplike)
  }

  closeCurrentTab() {
    const listing = this.tabber.currentElement
    let index
    this.undoManager.pushAction({
      activate: () => {
        index = this.tabber.currentElementIndex
        this.tabber.closeTab(this.tabber.currentElement)
      },
      undo: () => {
        this.tabber.addTab(listing, index)
        this.tabber.selectTab(listing)
      }
    })
  }

  shuffleQueue() {
    this.SQP.shuffleQueue()
  }

  clearQueue() {
    this.SQP.clearQueue()
    this.queueListingElement.selectNone()
    this.updateQueueLengthLabel()

    if (this.queueListingElement.isSelected && !this.queueListingElement.selectable) {
      this.root.select(this.tabber)
    }
  }

  // TODO: I'd like to name/incorporate this function better.. for now it's
  // just directly moved from the old event listener on grouplikeListings for
  // 'queue'.
  handleQueueOptions(item, {where = 'end', order = 'normal', play = false, skip = false} = {}) {
    if (!this.config.canControlQueue) {
      return
    }

    const passedItem = item

    let { playingTrack } = this.SQP

    if (skip && playingTrack === item) {
      this.SQP.playNext(playingTrack)
    }

    const oldName = item.name
    if (isGroup(item)) {
      switch (order) {
        case 'shuffle':
          item = {
            name: `${oldName} (shuffled)`,
            items: shuffleArray(flattenGrouplike(item).items)
          }
          break
        case 'shuffle-groups':
          item = shuffleOrderOfGroups(item)
          item.name = `${oldName} (group order shuffled)`
          break
        case 'reverse':
          item = {
            name: `${oldName} (reversed)`,
            items: flattenGrouplike(item).items.reverse()
          }
          break
        case 'reverse-groups':
          item = reverseOrderOfGroups(item)
          item.name = `${oldName} (group order reversed)`
          break
        case 'alphabetic':
          item = {
            name: `${oldName} (alphabetic)`,
            items: orderBy(
              flattenGrouplike(item).items,
              t => getNameWithoutTrackNumber(t).replace(/[^a-zA-Z0-9]/g, '')
            )
          }
          break
        case 'alphabetic-groups':
          item = {
            name: `${oldName} (group order alphabetic)`,
            items: orderBy(
              collapseGrouplike(item).items,
              t => t.name.replace(/[^a-zA-Z0-9]/g, '')
            )
          }
          break
      }
    } else {
      // Make it into a grouplike that just contains itself.
      item = {name: oldName, items: [item]}
    }

    if (where === 'next' || where === 'after-selected' || where === 'before-selected' || where === 'end') {
      const selected = this.queueListingElement.currentItem
      let afterItem = null
      if (where === 'next') {
        afterItem = playingTrack
      } else if (where === 'after-selected') {
        afterItem = selected
      } else if (where === 'before-selected') {
        const { items } = this.SQP.queueGrouplike
        const index = items.indexOf(selected)
        if (index === 0) {
          afterItem = 'FRONT'
        } else if (index > 0) {
          afterItem = items[index - 1]
        }
      }

      this.SQP.queue(item, afterItem, {
        movePlayingTrack: order === 'normal' || order === 'alphabetic'
      })

      if (isTrack(passedItem)) {
        this.queueListingElement.selectAndShow(passedItem)
      } else {
        this.queueListingElement.selectAndShow(selected)
      }
    } else if (where.startsWith('distribute-')) {
      this.SQP.distributeQueue(item, {
        how: where.slice('distribute-'.length)
      })
    }

    this.updateQueueLengthLabel()

    if (play) {
      this.play(item)
    }
  }

  async processMetadata(item, reprocess = false) {
    if (!this.config.canProcessMetadata) {
      return
    }

    if (this.clearMetadataStatusTimeout) {
      clearTimeout(this.clearMetadataStatusTimeout)
    }

    this.metadataStatusLabel.text = 'Processing metadata...'
    this.metadataStatusLabel.visible = true
    this.fixLayout()

    const counter = await this.backend.processMetadata(item, reprocess)

    const tracksMsg = (counter === 1) ? '1 track' : `${counter} tracks`
    this.metadataStatusLabel.text = `Done processing metadata of ${tracksMsg}!`

    this.clearMetadataStatusTimeout = setTimeout(() => {
      this.clearMetadataStatusTimeout = null
      this.metadataStatusLabel.text = ''
      this.metadataStatusLabel.visible = false
      this.fixLayout()
    }, 3000)
  }

  updateQueueLengthLabel() {
    if (!this.SQP) {
      this.queueTimeLabel.text = ''
      return
    }

    const { playingTrack, timeData, queueEndMode } = this.SQP
    const { items } = this.SQP.queueGrouplike
    const {
      currentInput: currentInput,
      currentItem: selectedTrack
    } = this.queueListingElement

    const isTimestamp = (currentInput instanceof TimestampGrouplikeItemElement)

    let trackRemainSec = 0
    let trackPassedSec = 0

    if (timeData) {
      const { curSecTotal = 0, lenSecTotal = 0 } = timeData
      trackRemainSec = lenSecTotal - curSecTotal
      trackPassedSec = curSecTotal
    }

    const playingIndex = items.indexOf(playingTrack)
    const selectedIndex = items.indexOf(selectedTrack)
    const timestampData = playingTrack && this.getTimestampData(playingTrack)

    // This will be set to a list of tracks, which will later be used to
    // calculate a particular duration (as described below) to be shown in
    // the time label.
    let durationRange

    // This will be added to the calculated duration before it is displayed.
    // It's used to account for the time of the current track, if that is
    // relevant to the particular duration being calculated.
    let durationAdd

    // This will be stuck behind the final duration when it is displayed. It's
    // used to indicate the "direction" of the calculated duration to the user.
    let durationSymbol

    // Depending on which track is selected relative to which track is playing
    // (and on whether any track is playing at all), display...
    if (!playingTrack) {
      // Full length of the queue.
      durationRange = items
      durationAdd = 0
      durationSymbol = ''
    } else if (
      selectedIndex === playingIndex &&
      (!isTimestamp || currentInput.isCurrentTimestamp)
    ) {
      // Remaining length of the queue.
      if (timeData) {
        durationRange = items.slice(playingIndex + 1)
        durationAdd = trackRemainSec
      } else {
        durationRange = items.slice(playingIndex)
        durationAdd = 0
      }
      durationSymbol = ''
    } else if (
      selectedIndex < playingIndex ||
      (isTimestamp && currentInput.data.timestamp <= trackPassedSec)
    ) {
      // Time since the selected track ended.
      durationRange = items.slice(selectedIndex + 1, playingIndex)
      durationAdd = trackPassedSec // defaults to 0: no need to check timeData
      durationSymbol = '-'
      if (isTimestamp) {
        if (selectedIndex < playingIndex) {
          durationRange.unshift(items[selectedIndex])
        }
        durationAdd -= currentInput.data.timestampEnd
      }
    } else if (
      selectedIndex > playingIndex ||
      (isTimestamp && currentInput.data.timestamp > trackPassedSec)
    ) {
      // Time until the selected track begins.
      if (timeData) {
        if (selectedIndex === playingIndex) {
          durationRange = []
          durationAdd = -trackPassedSec
        } else {
          durationRange = items.slice(playingIndex + 1, selectedIndex)
          durationAdd = trackRemainSec
        }
      } else {
        durationRange = items.slice(playingIndex, selectedIndex)
        durationAdd = 0
      }
      if (isTimestamp) {
        durationAdd += currentInput.data.timestamp
      }
      durationSymbol = '+'
    }

    // Use the duration* variables to calculate and display the specified
    // duration.
    const { seconds: durationCalculated, approxSymbol } = this.backend.getDuration({items: durationRange})
    const durationTotal = durationCalculated + durationAdd
    const { duration: durationString } = getTimeStringsFromSec(0, durationTotal)
    this.queueTimeLabel.text = `(${durationSymbol + durationString + approxSymbol})`

    if (playingTrack) {
      let trackPart
      let trackPartShort
      let trackPartReallyShort

      {
        const distance = Math.abs(selectedIndex - playingIndex)

        let insertString
        let insertStringShort
        if (selectedIndex < playingIndex) {
          insertString = ` (-${distance})`
          insertStringShort = `-${distance}`
        } else if (selectedIndex > playingIndex) {
          insertString = ` (+${distance})`
          insertStringShort = `+${distance}`
        } else {
          insertString = ''
          insertStringShort = ''
        }

        trackPart = `${playingIndex + 1 + insertString} / ${items.length}`
        trackPartShort = (insertString
          ? `${playingIndex + 1 + insertStringShort}/${items.length}`
          : `${playingIndex + 1}/${items.length}`)
        trackPartReallyShort = (insertString
          ? insertStringShort
          : `#${playingIndex + 1}`)
      }

      let timestampPart

      if (isTimestamp && selectedIndex === playingIndex) {
        const selectedTimestampIndex = timestampData.indexOf(currentInput.data)

        const found = timestampData.findIndex(ts => ts.timestamp > trackPassedSec)
        const playingTimestampIndex = (found >= 0 ? found - 1 : 0)
        const distance = Math.abs(selectedTimestampIndex - playingTimestampIndex)

        let insertString
        if (selectedTimestampIndex < playingTimestampIndex) {
          insertString = ` (-${distance})`
        } else if (selectedTimestampIndex > playingTimestampIndex) {
          insertString = ` (+${distance})`
        } else {
          insertString = ''
        }

        timestampPart = `${playingTimestampIndex + 1 + insertString} / ${timestampData.length}`
      }

      let queueLoopPart
      let queueLoopPartShort

      if (selectedIndex === playingIndex) {
        switch (queueEndMode) {
          case 'loop':
            queueLoopPart = 'Repeat'
            queueLoopPartShort = 'R'
            break
          case 'shuffle':
            queueLoopPart = 'Shuffle'
            queueLoopPartShort = 'S'
            break
          case 'end':
          default:
            break
        }
      }

      let partsTogether

      const all = () => `(${this.SQP.playSymbol} ${partsTogether})`
      const tooWide = () => all().length > this.queuePane.contentW

      // goto irl
      determineParts: {
        if (timestampPart) {
          if (queueLoopPart) {
            partsTogether = `${trackPart} : ${timestampPart} »${queueLoopPartShort}`
          } else {
            partsTogether = `(${this.SQP.playSymbol} ${trackPart} : ${timestampPart})`
          }
          break determineParts
        }

        if (queueLoopPart) includeQueueLoop: {
          partsTogether = `${trackPart} » ${queueLoopPart}`
          if (tooWide()) {
            partsTogether = `${trackPart} »${queueLoopPartShort}`
            if (tooWide()) {
              break includeQueueLoop
            }
          }
          break determineParts
        }

        partsTogether = trackPart
        if (tooWide()) {
          partsTogether = trackPartShort
          if (tooWide()) {
            partsTogether = trackPartReallyShort
          }
        }
      }

      this.queueLengthLabel.text = all()
    } else {
      this.queueLengthLabel.text = `(${items.length})`
    }

    // Layout stuff to position the length and time labels correctly.
    this.queueLengthLabel.centerInParent()
    this.queueTimeLabel.centerInParent()
    this.queueLengthLabel.y = this.queuePane.contentH - 2
    this.queueTimeLabel.y = this.queuePane.contentH - 1
  }

  updateQueueSelection(timeData, oldTimeData) {
    if (!timeData) {
      return
    }

    const { playingTrack } = this.SQP
    const { form } = this.queueListingElement
    const { currentInput } = form

    if (!currentInput || currentInput.item !== playingTrack) {
      return
    }

    const timestamps = this.getTimestampData(playingTrack)

    if (!timestamps) {
      return
    }

    const tsOld = oldTimeData &&
      this.getTimestampAtSec(playingTrack, oldTimeData.curSecTotal)
    const tsNew =
      this.getTimestampAtSec(playingTrack, timeData.curSecTotal)

    if (
      tsNew !== tsOld &&
      currentInput instanceof TimestampGrouplikeItemElement &&
      currentInput.data === tsOld
    ) {
      const index = form.inputs.findIndex(el => (
        el.item === playingTrack &&
        el instanceof TimestampGrouplikeItemElement &&
        el.data === tsNew
      ))

      if (index === -1) {
        return
      }

      form.curIndex = index
      if (form.isSelected) {
        form.updateSelectedElement()
      }
      form.scrollSelectedElementIntoView()
    }
  }

  setThemeColor(color) {
    this.themeColor = color
    this.menubar.color = color
  }

  get SQP() {
    // Just a convenient shorthand.
    return this.selectedQueuePlayer
  }

  get selectedQueuePlayer() { return this.getDep('selectedQueuePlayer') }
  set selectedQueuePlayer(v) { this.setDep('selectedQueuePlayer', v) }
}

class GrouplikeListingElement extends Form {
  // TODO: This is a Form, which means that it captures the tab key. The result
  // of this is that you cannot use Tab to navigate the top-level application.
  // Accordingly, I've made AppElement a FocusElement and not a Form and re-
  // factored calls of addInput to addChild. However, I'm not sure that this is
  // the "correct" or most intuitive behavior. Should the tab key be usable to
  // navigate the entire interface? I don't know. I've gone with the current
  // behavior (GrouplikeListingElement as a Form) because it feels right at the
  // moment, but we'll see, I suppose.
  //
  // In order to let tab navigate through all UI elements (or rather, the top-
  // level application as well as GrouplikeListingElements, which are a sort of
  // nested Form), the AppElement would have to be changed to be a Form again
  // (replacing addChild with addInput where appropriate). Furthermore, while
  // the GrouplikeListingElement should stay as a Form subclass, it should be
  // modified so that it does not capture tab if there is no next element to
  // select, and vice versa for shift-tab and the previous element. This should
  // probably be implemented in tui-lib as a flag on Form (captureTabOnEnds,
  // or something).
  //
  // (PS AppElement apparently used a "this.form" property, instead of directly
  // inheriting from Form, apparently. That's more or less adjacent to the
  // point. It's removed now. You'll have to add it back, if wanted.)
  //
  // August 15th, 2018

  constructor(app) {
    super()

    this.grouplike = null
    this.app = app

    this.form = this.getNewForm()
    this.addInput(this.form)

    this.form.on('select', input => {
      if (input && this.pathElement) {
        this.pathElement.showItem(input.item)
        this.autoscroll()
        this.emit('select', input.item)
      }
    })

    this.jumpElement = new ListingJumpElement()
    this.addChild(this.jumpElement)
    this.jumpElement.visible = false
    this.oldFocusedIndex = null // To restore to, if a jump is canceled.
    this.previousJumpValue = '' // To default to, if the user doesn't enter anything.

    this.jumpElement.on('cancel', () => this.hideJumpElement(true))
    this.jumpElement.on('change', value => this.handleJumpValue(value, false))
    this.jumpElement.on('confirm', value => this.handleJumpValue(value, true))

    this.pathElement = new PathElement()
    this.addInput(this.pathElement)

    this.commentLabel = new WrapLabel()
    this.addChild(this.commentLabel)

    this.grouplikeData = new WeakMap()

    this.autoscrollOffset = null
    this.expandedTimestamps = []
  }

  getNewForm() {
    return new GrouplikeListingForm(this.app)
  }

  fixLayout() {
    this.commentLabel.w = this.contentW

    this.form.w = this.contentW
    this.form.h = this.contentH
    this.form.y = this.commentLabel.bottom
    this.form.h -= this.commentLabel.h
    this.form.h -= 1 // For the path element
    if (this.jumpElement.visible) this.form.h -= 1

    this.form.fixLayout() // Respond to being resized
    this.autoscroll()
    this.form.scrollSelectedElementIntoView()

    this.pathElement.y = this.contentH - 1
    this.pathElement.w = this.contentW

    this.jumpElement.y = this.pathElement.y - 1
    this.jumpElement.w = this.contentW
  }

  selected() {
    this.curIndex = 0
    this.root.select(this.form)
    this.emit('select', this.currentItem)
  }

  clicked(button) {
    if (button === 'left') {
      this.selected()
      return false
    }
  }

  get selectable() {
    return this.form.selectable
  }

  keyPressed(keyBuf) {
    // Just about everything here depends on the grouplike existing, so let's
    // not continue if it doesn't!
    if (!this.grouplike) {
      return
    }

    if (telc.isBackspace(keyBuf)) {
      this.loadParentGrouplike()
    } else if (telc.isCharacter(keyBuf, '/') || keyBuf[0] === 6) { // '/', ctrl-F
      this.showJumpElement()
    } else if (input.isScrollToStart(keyBuf)) {
      this.form.selectAndShow(this.grouplike.items[0])
      this.form.scrollToBeginning()
    } else if (input.isScrollToEnd(keyBuf)) {
      this.form.selectAndShow(this.grouplike.items[this.grouplike.items.length - 1])
    } else if (keyBuf[0] === 12) { // ctrl-L
      if (this.grouplike.isTheQueue) {
        this.form.selectAndShow(this.app.SQP.playingTrack)
        /*
      } else {
        this.toggleExpandLabels()
        */
      }
    } else if (keyBuf[0] === 1) { // ctrl-A
      this.toggleMarkAll()
    } else {
      return super.keyPressed(keyBuf)
    }
  }

  loadGrouplike(grouplike, resetIndex = true) {
    this.saveGrouplikeData()
    this.grouplike = grouplike
    this.buildItems(resetIndex)
    this.restoreGrouplikeData()

    if (this.root.select) this.hideJumpElement()
  }

  saveGrouplikeData() {
    if (isGroup(this.grouplike)) {
      this.grouplikeData.set(this.grouplike, {
        scrollItems: this.form.scrollItems,
        currentItem: this.currentItem,
        expandedTimestamps: this.expandedTimestamps
      })
    }
  }

  restoreGrouplikeData() {
    if (this.grouplikeData.has(this.grouplike)) {
      const data = this.grouplikeData.get(this.grouplike)
      this.form.scrollItems = data.scrollItems
      this.form.selectAndShow(data.currentItem)
      this.form.fixLayout()
      this.expandedTimestamps = data.expandedTimestamps
      this.buildTimestampItems()
    }
  }

  selectNone() {
    // nb: this is unrelated to the actual track selection system!
    // just clears the form selection
    this.pathElement.showItem(null)
    this.form.curIndex = 0
    this.form.scrollItems = 0
  }

  toggleMarkAll() {
    const { items } = this.grouplike
    const actions = []
    const tracks = flattenGrouplike(this.grouplike).items
    if (items.every(item => this.app.getMarkStatus(item) !== 'unmarked')) {
      if (this.app.markGrouplike.items.length > tracks.length) {
        actions.push({label: 'Remove from selection', action: () => this.app.unmarkItem(this.grouplike)})
      }
      actions.push({label: 'Clear selection', action: () => this.app.unmarkAll()})
    } else {
      actions.push({label: 'Add to selection', action: () => this.app.markItem(this.grouplike)})
      if (this.app.markGrouplike.items.some(item => !tracks.includes(item))) {
        actions.push({label: 'Replace selection', action: () => {
          this.app.unmarkAll()
          this.app.markItem(this.grouplike)
        }})
      }
    }
    if (actions.length === 1) {
      actions[0].action()
    } else {
      const el = this.form.inputs[this.form.curIndex]
      this.app.showContextMenu({
        x: el.absLeft,
        y: el.absTop + 1,
        items: actions
      })
    }
  }

  /*
  toggleExpandLabels() {
    this.expandLabels = !this.expandLabels
    for (const input of this.form.inputs) {
      if (!(input instanceof InteractiveGrouplikeItemElement)) {
        continue
      }
      if (!input.labelsSelected) {
        input.expandLabels = this.expandLabels
        input.computeText()
      }
    }
  }
  */

  toggleAutoscroll() {
    if (this.autoscrollOffset === null) {
      this.autoscrollOffset = this.form.curIndex - this.form.scrollItems
      this.form.wheelMode = 'selection'
    } else {
      this.autoscrollOffset = null
      this.form.wheelMode = 'scroll'
    }
  }

  autoscroll() {
    if (this.autoscrollOffset !== null) {
      const distanceFromTop = this.form.curIndex - this.form.scrollItems
      const delta = this.autoscrollOffset - distanceFromTop
      this.form.scrollItems -= delta
      this.form.fixLayout()
    }
  }

  expandTimestamps(item) {
    if (this.grouplike && this.grouplike.items.includes(item)) {
      const ET = this.expandedTimestamps
      if (!ET.includes(item)) {
        this.expandedTimestamps.push(item)
        this.buildTimestampItems()

        if (this.currentItem === item) {
          if (this.isSelected) {
            this.form.selectInput(this.form.inputs[this.form.curIndex + 1])
          } else {
            this.form.curIndex += 1
          }
        }
      }
    }
  }

  collapseTimestamps(item) {
    const ET = this.expandedTimestamps // :alien:
    if (ET.includes(item)) {
      const restore = (this.currentItem === item)

      ET.splice(ET.indexOf(item), 1)
      this.buildTimestampItems()

      if (restore) {
        const { form } = this
        const index = form.inputs.findIndex(inp => inp.item === item)
        form.curIndex = index
        if (form.isSelected) {
          form.updateSelectedElement()
        }
        form.scrollSelectedElementIntoView()
      }
    }
  }

  toggleTimestamps(item) {
    if (this.timestampsExpanded(item)) {
      this.collapseTimestamps(item)
    } else {
      this.expandTimestamps(item)
    }
  }

  timestampsExpanded(item) {
    this.updateTimestamps()
    return this.expandedTimestamps.includes(item)
  }

  selectTimestampAtSec(item, sec) {
    this.expandTimestamps(item)

    const { form } = this
    let index = form.inputs.findIndex(el => (
      el.item === item &&
      el instanceof TimestampGrouplikeItemElement &&
      el.data.timestamp >= sec
    ))

    if (index === -1) {
      index = form.inputs.findIndex(el => el.item === item)
      if (index === -1) {
        return
      }
    }

    form.curIndex = index
    if (form.isSelected) {
      form.updateSelectedElement()
    }
    form.scrollSelectedElementIntoView()
  }

  updateTimestamps() {
    const ET = this.expandedTimestamps
    if (ET) {
      this.expandedTimestamps = ET.filter(item => this.grouplike.items.includes(item))
    }
  }

  restoreSelectedInput(restoreInput) {
    const { form } = this
    const { inputs, currentInput } = form

    if (currentInput === restoreInput) {
      return
    }

    let inputToSelect

    if (inputs.includes(restoreInput)) {
      inputToSelect = restoreInput
    } else if (restoreInput instanceof InteractiveGrouplikeItemElement) {
      inputToSelect = inputs.find(input =>
        input.item === restoreInput.item &&
        input instanceof InteractiveGrouplikeItemElement
      )
    } else if (restoreInput instanceof TimestampGrouplikeItemElement) {
      inputToSelect = inputs.find(input =>
        input.data === restoreInput.data &&
        input instanceof TimestampGrouplikeItemElement
      )
    }

    if (!inputToSelect) {
      return
    }

    form.curIndex = inputs.indexOf(inputToSelect)
    if (form.isSelected) {
      form.updateSelectedElement()
    }
    form.scrollSelectedElementIntoView()
  }

  buildTimestampItems(restoreInput = this.currentInput) {
    const form = this.form

    // Clear up any existing timestamp items, since we're about to generate new
    // ones!
    form.children = form.children.filter(child => !(child instanceof TimestampGrouplikeItemElement))
    form.inputs = form.inputs.filter(child => !(child instanceof TimestampGrouplikeItemElement))

    this.updateTimestamps()

    if (!this.expandedTimestamps) {
      // Well that's going to have obvious consequences.
      return
    }

    for (const item of this.expandedTimestamps) {
      // Find the main item element. The items we're about to generate will be
      // inserted after it.
      const mainElementIndex = form.inputs.findIndex(el => (
        el instanceof InteractiveGrouplikeItemElement &&
        el.item === item
      ))

      const timestampData = this.app.getTimestampData(item)

      // Oh no.
      // TODO: This should probably error report lol.
      if (!timestampData) {
        continue
      }

      // Generate some items! Just go over the data list and generate one for
      // each timestamp.
      const tsElements = timestampData.map(ts => {
        const el = new TimestampGrouplikeItemElement(item, ts, timestampData, this.app)
        el.on('pressed', () => this.emit('timestamp', item, ts.timestamp))
        if (this.grouplike.isTheQueue) {
          el.hideMetadata = true
        }
        return el
      })

      // Stick 'em in. Form doesn't implement an "insert input" function because
      // why would life be easy, so we'll mangle the inputs array ourselves.

      form.inputs.splice(mainElementIndex + 1, 0, ...tsElements)

      let previousIndex = mainElementIndex
      for (const el of tsElements) {
        // We do addChild rather than a simple splice because addChild does more
        // stuff than just sticking it in the array (e.g. setting the child's
        // .parent property). What if addInput gets updated to do more stuff in
        // a similar fashion? Well, then we're scr*wed! :)
        form.addChild(el, previousIndex + 1)
        previousIndex++
      }
    }

    this.restoreSelectedInput(restoreInput)
    this.scheduleDrawWithoutPropertyChange()
    this.fixAllLayout()
  }

  buildItems(resetIndex = false) {
    if (!this.grouplike) {
      throw new Error('Attempted to call buildItems before a grouplike was loaded')
    }

    this.commentLabel.text = this.grouplike.comment || ''

    const restoreInput = this.form.currentInput
    const wasSelected = this.isSelected
    const form = this.form

    // Just outright scrap the old items - don't deal with any selection stuff
    // (as a result of removeInput) yet.
    form.children = form.children.filter(child => !form.inputs.includes(child));
    form.inputs = []

    const parent = this.grouplike[parentSymbol]
    if (parent) {
      const upButton = new BasicGrouplikeItemElement(`Up (to ${parent.name || 'unnamed group'})`)
      upButton.on('pressed', () => this.loadParentGrouplike())
      form.addInput(upButton)
    }

    if (this.grouplike.items.length) {
      // Add an element for controlling this whole group. Particularly handy
      // for operating on the top-level group, which itself is not contained
      // within any groups (so you can't browse a parent and access its menu
      // from there).
      if (!this.grouplike.isTheQueue) {
        const ownElement = new BasicGrouplikeItemElement(`This group: ${this.grouplike.name || '(Unnamed group)'}`)
        ownElement.item = this.grouplike
        ownElement.app = this.app
        ownElement.isGroup = true
        ownElement.on('pressed', () => {
          ownElement.emit('menu', ownElement)
        })
        this.addEventListeners(ownElement)
        form.addInput(ownElement)
      }

      // Add the elements for all the actual items within this playlist.
      for (const item of this.grouplike.items) {
        if (!isPlayable(item) && getCorrespondingPlayableForFile(item)) {
          continue
        }

        const itemElement = new InteractiveGrouplikeItemElement(item, this.app)
        this.addEventListeners(itemElement)
        form.addInput(itemElement)

        if (this.grouplike.isTheQueue) {
          itemElement.hideMetadata = true
          itemElement.text = getNameWithoutTrackNumber(item)
        }
      }
    } else if (!this.grouplike.isTheQueue) {
      form.addInput(new BasicGrouplikeItemElement('(This group is empty)'))
    }

    if (wasSelected) {
      if (resetIndex) {
        form.scrollItems = 0
        form.selectInput(form.inputs[form.firstItemIndex])
      } else {
        this.root.select(form)
      }
    }

    this.buildTimestampItems(restoreInput)

    // Just to make the selected-track-info bar fill right away (if it wasn't
    // already filled by a previous this.curIndex set).
    /* eslint-disable-next-line no-self-assign */
    form.curIndex = form.curIndex

    this.fixAllLayout()
  }

  addEventListeners(itemElement) {
    for (const evtName of [
      'browse',
      'download',
      'edit-notes',
      'mark',
      'menu',
      'open',
      'paste',
      'queue',
      'remove',
      'unqueue'
    ]) {
      itemElement.on(evtName, (...data) => this.emit(evtName, itemElement.item, ...data))
    }

    itemElement.on('toggle-timestamps', () => this.toggleTimestamps(itemElement.item))

    /*
    itemElement.on('unselected labels', () => {
      if (!this.expandLabels) {
        itemElement.expandLabels = false
        itemElement.computeText()
      }
    })
    */
  }

  loadParentGrouplike() {
    if (!this.grouplike) {
      return
    }

    const parent = this.grouplike[parentSymbol]
    if (parent) {
      const form = this.form
      const oldGrouplike = this.grouplike

      this.loadGrouplike(parent)
      form.curIndex = form.firstItemIndex
      this.restoreGrouplikeData()

      const index = form.inputs.findIndex(inp => inp.item === oldGrouplike)
      if (typeof index === 'number') {
        form.curIndex = index
      }

      form.updateSelectedElement()
      form.scrollSelectedElementIntoView()
    }
  }

  selectAndShow(item) {
    return this.form.selectAndShow(item)
  }

  handleJumpValue(value, isConfirm) {
    // If the user doesn't enter anything, we won't perform a search -- unless
    // the user just pressed enter. If that's the case, we'll search for
    // whatever was previously entered into the form. This is to strike a
    // balance between keeping the jump form simple and unsurprising but also
    // powerful, i.e. to support easy "repeated" searches (see the below
    // cmoment about search match prioritization).
    if (!value.length && isConfirm && this.previousJumpValue) {
      value = this.previousJumpValue
    }

    const grouplike = {items: this.form.inputs.map(inp => inp.item)}

    // We prioritize searching past the index that the user opened the jump
    // element from (oldFocusedIndex). This is so that it's more practical
    // to do a "repeated" search, wherein the user searches for the same
    // value over and over, each time jumping to the next match, until they
    // have found the one they're looking for.
    const preferredStartIndex = this.oldFocusedIndex

    const item = searchForItem(grouplike, value, preferredStartIndex)

    if (item) {
      this.form.curIndex = this.form.inputs.findIndex(inp => inp.item === item)
      this.form.scrollSelectedElementIntoView()
    } else {
      // TODO: Feedback that the search failed.. right now we just close the
      // jump-to menu, which might not be right.
    }

    if (isConfirm) {
      this.previousJumpValue = value
      this.hideJumpElement()
    }
  }

  showJumpElement() {
    this.oldFocusedIndex = this.form.curIndex
    this.jumpElement.visible = true
    this.root.select(this.jumpElement)
    this.fixLayout()
  }

  hideJumpElement(isCancel) {
    if (this.jumpElement.visible) {
      if (isCancel) {
        this.form.curIndex = this.oldFocusedIndex
        this.form.scrollSelectedElementIntoView()
      }
      this.jumpElement.visible = false
      if (this.jumpElement.isSelected) {
        this.root.select(this)
      }
      this.fixLayout()
    }
  }

  unselected() {
    this.hideJumpElement(true)
  }

  get tabberLabel() {
    if (this.grouplike) {
      return this.grouplike.name || 'Unnamed group'
    } else {
      return 'No group open'
    }
  }

  get currentItem() {
    const element = this.currentInput
    return element && element.item
  }

  get currentInput() {
    return this.form.currentInput
  }
}

class GrouplikeListingForm extends ListScrollForm {
  constructor(app) {
    super('vertical')

    this.app = app
    this.dragInputs = []
    this.selectMode = null
    this.keyboardDragDirection = null
    this.captureTab = false
  }

  keyPressed(keyBuf) {
    if (this.inputs.length === 0) {
      return
    }

    if (input.isSelectUp(keyBuf)) {
      this.selectUp()
    } else if (input.isSelectDown(keyBuf)) {
      this.selectDown()
    } else {
      if (telc.isUp(keyBuf) || telc.isDown(keyBuf)) {
        this.keyboardDragDirection = null
      }
      return super.keyPressed(keyBuf)
    }
  }

  set curIndex(newIndex) {
    this.setDep('curIndex', newIndex)
    this.emit('select', this.inputs[this.curIndex])
  }

  get curIndex() {
    return this.getDep('curIndex')
  }

  get firstItemIndex() {
    return Math.max(0, this.inputs.findIndex(el => el instanceof InteractiveGrouplikeItemElement))
  }

  get currentInput() {
    return this.inputs[this.curIndex]
  }

  selectAndShow(item) {
    const index = this.inputs.findIndex(inp => inp.item === item)
    if (index >= 0) {
      this.curIndex = index
      if (this.isSelected) {
        this.updateSelectedElement()
      }
      this.scrollSelectedElementIntoView()
      return true
    }
    return false
  }

  clicked(button, allData) {
    const { line, ctrl } = allData
    if (button === 'left') {
      this.dragStartLine = line - this.absTop + this.scrollItems
      this.dragStartIndex = this.inputs.findIndex(inp => inp.absTop === line - 1)
      if (this.dragStartIndex >= 0) {
        const input = this.inputs[this.dragStartIndex]
        if (!(input instanceof InteractiveGrouplikeItemElement)) {
          this.dragStartIndex = -1
          return
        }
        const { item } = input
        if (this.app.getMarkStatus(item) === 'unmarked') {
          if (!ctrl) {
            this.app.unmarkAll()
          }
          this.selectMode = 'select'
        } else {
          this.selectMode = 'deselect'
        }
        if (ctrl) {
          this.dragInputs = [item]
          this.dragEnteredRange(item)
        } else {
          this.dragInputs = []
        }
        this.oldMarkedItems = this.app.markGrouplike.items.slice()
      }
    } else if (button === 'drag-left' && this.dragStartIndex >= 0) {
      const offset = (line - this.absTop + this.scrollItems) - this.dragStartLine
      const rangeA = this.dragStartIndex
      const rangeB = this.dragStartIndex + offset
      const inputs = ((rangeA < rangeB)
        ? this.inputs.slice(rangeA, rangeB + 1)
        : this.inputs.slice(rangeB, rangeA + 1))
      let enteredRange = inputs.filter(inp => !this.dragInputs.includes(inp))
      let leftRange = this.dragInputs.filter(inp => !inputs.includes(inp))
      for (const { item } of enteredRange) {
        this.dragEnteredRange(item)
      }
      for (const { item } of leftRange) {
        this.dragLeftRange(item)
      }
      if (this.inputs[rangeB]) {
        this.root.select(this.inputs[rangeB])
      }
      this.dragInputs = inputs
    } else if (button === 'release') {
      this.dragStartIndex = -1
    } else {
      return super.clicked(button, allData)
    }
  }

  dragEnteredRange(item) {
    if (this.selectMode === 'select') {
      this.app.markItem(item)
    } else if (this.selectMode === 'deselect') {
      this.app.unmarkItem(item)
    }
  }

  dragLeftRange(item) {
    if (this.selectMode === 'select') {
      if (!this.oldMarkedItems.includes(item)) {
        this.app.unmarkItem(item)
      }
    } else if (this.selectMode === 'deselect') {
      if (this.oldMarkedItems.includes(item)) {
        this.app.markItem(item)
      }
    }
  }

  selectUp() {
    this.handleKeyboardSelect(-1)
  }

  selectDown() {
    this.handleKeyboardSelect(+1)
  }

  handleKeyboardSelect(direction) {
    const move = () => {
      if (direction === +1) {
        this.nextInput()
      } else {
        this.previousInput()
      }
      this.scrollSelectedElementIntoView()
    }

    const getItem = () => {
      const input = this.inputs[this.curIndex]
      if (input instanceof InteractiveGrouplikeItemElement) {
        return input.item
      } else {
        return null
      }
    }

    if (!this.keyboardDragDirection) {
      const item = getItem()
      if (!item) {
        move()
        return
      }
      this.keyboardDragDirection = direction
      this.oldMarkedItems = (this.inputs
        .filter(input => input.item && this.app.getMarkStatus(input.item) !== 'unmarked')
        .map(input => input.item))
      if (this.app.getMarkStatus(item) === 'unmarked') {
        this.selectMode = 'select'
      } else {
        this.selectMode = 'deselect'
      }
      this.dragEnteredRange(item)
    }

    if (direction === this.keyboardDragDirection) {
      move()
      const item = getItem()
      if (!item) {
        return
      }
      this.dragEnteredRange(item)
    } else {
      const item = getItem()
      if (!item) {
        move()
        return
      }
      this.dragLeftRange(item)
      move()
    }
  }
}

class BasicGrouplikeItemElement extends Button {
  constructor(text) {
    super()

    this._text = this._rightText = ''

    this.text = text
    this.rightText = ''
    this.drawText = ''
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = 1

    this.computeText()
  }

  set text(val) {
    if (this._text !== val) {
      this._text = val
      this.computeText()
    }
  }

  get text() {
    return this._text
  }

  set rightText(val) {
    if (this._rightText !== val) {
      this._rightText = val
      this.computeText()
    }
  }

  get rightText() {
    return this._rightText
  }

  getFormattedRightText() {
    return this.rightText
  }

  getRightTextColumns() {
    return ansi.measureColumns(this.rightText)
  }

  getMinLeftTextColumns() {
    return 12
  }

  getLeftPadding() {
    return 2
  }

  getSelfSelected() {
    return this.isSelected
  }

  computeText() {
    let w = this.w - this.x - this.getLeftPadding()

    // Also make space for the right text - if we choose to show it.
    const rightTextCols = this.getRightTextColumns()
    const showRightText = (w - rightTextCols > this.getMinLeftTextColumns())
    if (showRightText) {
      w -= rightTextCols
    }

    let text = ansi.trimToColumns(this.text, w)

    const width = ansi.measureColumns(this.text)
    if (width < w) {
      text += ' '.repeat(w - width)
    }

    if (showRightText) {
      text += this.getFormattedRightText()
    }

    text += ansi.resetAttributes()

    this.drawText = text
  }

  drawTo(writable) {
    const isCurrentInput = this.parent.inputs[this.parent.curIndex] === this
    // This line's commented out for now, so it'll show as selected (but
    // dimmed) even if you don't have the listing selected. To change that,
    // uncomment this and add it to the isCurrentInput line.
    // const isListingSelected = this.parent.parent.isSelected
    const isSelfSelected = this.getSelfSelected()

    if (isSelfSelected) {
      writable.write(ansi.invert())
    } else if (isCurrentInput) {
      // technically cheating - isPlayable is defined on InteractiveGrouplikeElement
      if (this.isPlayable === false) {
        writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.C_BLACK, ansi.A_BRIGHT]))
      } else {
        writable.write(ansi.setAttributes([ansi.A_INVERT, ansi.A_DIM]))
      }
    }

    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
    this.writeStatus(writable)
    writable.write(this.drawText)
  }

  writeStatus(writable) {
    // Add a couple spaces. This is less than the padding of the status text
    // of elements which represent real playlist items; that's to distinguish
    // "fake" rows from actual playlist items.
    writable.write('  ')
    this.drawX += 2
  }

  keyPressed(keyBuf) {
    // This function is overridden by InteractiveGrouplikeItemElement, but
    // it's still specified here that only enter counts as an action key.
    // By default for buttons, the space key also works, but since in this app
    // space is generally bound to mean "pause" instead of "select", we don't
    // check if space is pressed here.
    if (telc.isEnter(keyBuf) || input.isMenu(keyBuf)) {
      this.emit('pressed')
    }
  }

  clicked(button) {
    super.clicked(button)
  }
}

class InlineListPickerElement extends FocusElement {
  // And you thought my class names couldn't get any worse...
  // This is an element that looks something like the following:
  //    Fruit?  [Apple]
  // (Imagine that "[Apple]" just looks like "Apple" written in blue text.)
  // If you press the element (like a button), it'll pick the next item in its
  // list of options, like "Banana" or "Canteloupe" in this example. The arrow
  // keys also work to move through the list. You typically don't want to put
  // too many items in the list, since there's no visual way of telling what's
  // next or previous. (That's the point, it's inline.) This element is mainly
  // useful in forms or ContextMenus.

  constructor(labelText, options, optsOrShowContextMenu = null) {
    super()

    this.labelText = labelText
    this.options = options

    if (typeof optsOrShowContextMenu === 'function') {
      this.showContextMenu = optsOrShowContextMenu
    }

    if (typeof optsOrShowContextMenu === 'object') {
      const opts = optsOrShowContextMenu
      this.showContextMenu = opts.showContextMenu
      this.getValue = opts.getValue
      this.setValue = opts.setValue
    }

    this.keyboardIdentifier = this.labelText

    this.curIndex = 0
    this.refreshValue()
  }

  fixLayout() {
    // We want to fill the parent's width, but also fit ourselves, so we need
    // to determine the ideal width which would fit us but not leave extra
    // space.
    const longestOptionLength = this.options.reduce(
      (soFar, { label }) => Math.max(soFar, ansi.measureColumns(label)), 0)
    const idealWidth = (
      ansi.measureColumns(this.labelText) + longestOptionLength + 4)

    // Then we use whichever is greater - our ideal width or the width of the
    // parent - as our own width. The parent should respect our needs by
    // growing if necessary. :)  (ContextMenu does this, which is where you'd
    // typically embed this element.)
    // I shall fill you, parent, even beyond your own bounds!!!
    this.w = Math.max(this.parent.contentW, idealWidth)

    // Height is always just 1.
    this.h = 1
  }

  drawTo(writable) {
    if (this.isSelected) {
      writable.write(ansi.invert())
    }

    const curOption = this.options[this.curIndex].label.toString()
    let drawX = 0
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))

    writable.write(this.labelText + ' ')
    drawX += ansi.measureColumns(this.labelText) + 1

    writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
    writable.write(' ' + curOption + ' ')
    drawX += ansi.measureColumns(curOption) + 2

    writable.write(ansi.setForeground(ansi.C_RESET))
    writable.write(' '.repeat(Math.max(0, this.w - drawX)))

    writable.write(ansi.resetAttributes())
  }

  keyPressed(keyBuf) {
    if (telc.isSelect(keyBuf) || telc.isRight(keyBuf)) {
      this.nextOption()
    } else if (telc.isLeft(keyBuf)) {
      this.previousOption()
    } else if (input.isMenu(keyBuf) && this.showContextMenu) {
      this.showMenu()
    } else {
      return true
    }
    return false
  }

  clicked(button) {
    if (button === 'left') {
      if (this.isSelected) {
        this.nextOption()
      } else {
        this.root.select(this)
      }
    } else if (button === 'right') {
      this.showMenu()
    } else if (button === 'scroll-up') {
      this.previousOption()
    } else if (button === 'scroll-down') {
      this.nextOption()
    } else {
      return true
    }
    return false
  }


  showMenu() {
    this.showContextMenu({
      x: this.absLeft + ansi.measureColumns(this.labelText) + 1,
      y: this.absTop + 1,
      items: this.options.map(({ label }, index) => ({
        label,
        action: () => {
          this.curIndex = index
        },
        isDefault: index === this.curIndex
      }))
    })
  }

  refreshValue() {
    if (this.getValue) {
      const value = this.getValue()
      const index = this.options.findIndex(opt => opt.value === value)
      if (index >= 0) {
        this.curIndex = index
      }
    }
  }

  nextOption() {
    this.curIndex++
    if (this.curIndex === this.options.length) {
      this.curIndex = 0
    }

    if (this.setValue) {
      this.setValue(this.curValue)
    }
  }

  previousOption() {
    this.curIndex--
    if (this.curIndex < 0) {
      this.curIndex = this.options.length - 1
    }

    if (this.setValue) {
      this.setValue(this.curValue)
    }
  }

  get curValue() {
    return this.options[this.curIndex].value
  }

  get curIndex() { return this.getDep('curIndex') }
  set curIndex(v) { this.setDep('curIndex', v) }
}

// Quite hacky, but ATM I can't think of any way to neatly tie getDep/setDep
// into the slider and toggle elements.
const drawAfter = (fn, thisObj) => (...args) => {
  const ret = fn(...args)
  thisObj.scheduleDrawWithoutPropertyChange()
  return ret
}

class SliderElement extends FocusElement {
  // Same general principle and usage as InlineListPickerElement, but for
  // changing a numeric value.

  constructor(labelText, {setValue, getValue, maxValue = 100, percent = true, getEnabled = () => true}) {
    super()
    this.labelText = labelText
    this.setValue = drawAfter(setValue, this)
    this.getValue = getValue
    this.getEnabled = getEnabled
    this.maxValue = maxValue
    this.percent = percent
    this.keyboardIdentifier = this.labelText
  }

  fixLayout() {
    const idealWidth = ansi.measureColumns(
      this.labelText +
      '  ' + this.getValueString(this.maxValue) +
      ' ' + this.getNumString(this.maxValue) +
      '  '
    )

    this.w = Math.max(this.parent.contentW, idealWidth)
    this.h = 1
  }

  drawTo(writable) {
    const enabled = this.getEnabled()

    if (this.isSelected) {
      writable.write(ansi.invert())
    }

    let drawX = 0
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))

    if (!enabled) {
      writable.write(ansi.setAttributes([ansi.A_DIM, ansi.C_WHITE]))
    }

    writable.write(this.labelText + ' ')
    drawX += ansi.measureColumns(this.labelText) + 1

    if (enabled) {
      writable.write(ansi.setAttributes([ansi.A_BRIGHT, ansi.C_BLUE]))
    }
    writable.write(' ')
    drawX += 1

    const valueString = this.getValueString(this.getValue())
    writable.write(valueString)
    drawX += valueString.length

    const numString = this.getNumString(this.getValue())
    writable.write(' ' + numString + ' ')
    drawX += numString.length + 2

    if (enabled) {
      writable.write(ansi.setForeground(ansi.C_RESET))
    }

    writable.write(' '.repeat(Math.max(0, this.w - drawX)))

    writable.write(ansi.resetAttributes())
  }

  getValueString(value) {
    const maxLength = 10

    let length = Math.round(value / this.maxValue * maxLength)

    if (value < this.maxValue && length === maxLength) {
      length--
    }

    if (value > 0 && length === 0) {
      length++
    }

    return (
      '[' +
      '-'.repeat(length) +
      ' '.repeat(maxLength - length) +
      ']'
    )
  }

  getNumString(value) {
    const maxValueString = Math.round(this.maxValue).toString()
    const valueString = Math.round(value).toString()
    const paddedString = valueString.padStart(maxValueString.length)

    return paddedString + (this.percent ? '%' : '')
  }

  keyPressed(keyBuf) {
    const enabled = this.getEnabled()
    if (enabled && telc.isRight(keyBuf)) {
      this.increment()
    } else if (enabled && telc.isLeft(keyBuf)) {
      this.decrement()
    } else {
      return true
    }
    return false
  }

  clicked(button) {
    if (!this.getEnabled()) {
      return
    }

    if (button === 'left') {
      if (this.isSelected) {
        if (this.getValue() === this.maxValue) {
          this.setValue(0)
        } else {
          this.increment()
        }
      } else {
        this.root.select(this)
      }
    } else if (button === 'scroll-up') {
      this.increment()
    } else if (button === 'scroll-down') {
      this.decrement()
    }
  }

  increment() {
    this.setValue(this.getValue() + this.step)
  }

  decrement() {
    this.setValue(this.getValue() - this.step)
  }

  get step() {
    return this.maxValue / 10
  }
}

class ToggleControl extends FocusElement {
  constructor(labelText, {setValue, getValue, getEnabled = () => true}) {
    super()
    this.labelText = labelText
    this.setValue = drawAfter(setValue, this)
    this.getValue = getValue
    this.getEnabled = getEnabled
    this.keyboardIdentifier = this.labelText
  }

  keyPressed(keyBuf) {
    if (input.isSelect(keyBuf) && this.getEnabled()) {
      this.toggle()
    }
  }

  clicked(button) {
    if (!this.getEnabled()) {
      return
    }

    if (button === 'left') {
      if (this.isSelected) {
        this.toggle()
      } else {
        this.root.select(this)
      }
    } else if (button === 'scroll-up' || button === 'scroll-down') {
      this.toggle()
    } else {
      return true
    }
    return false
  }


  // Note: ToggleControl doesn't specify refreshValue because it doesn't have an
  // internal state for the current value. It sets and draws based on the value
  // getter provided externally.
  toggle() {
    this.setValue(!this.getValue())
  }

  fixLayout() {
    // Same general principle as ToggleControl - fill the parent, but always
    // fit ourselves!
    this.w = Math.max(this.parent.contentW, this.labelText.length + 5)
    this.h = 1
  }

  drawTo(writable) {
    if (this.isSelected) {
      writable.write(ansi.invert())
    }

    if (!this.getEnabled()) {
      writable.write(ansi.setAttributes([ansi.C_WHITE, ansi.A_DIM]))
    }

    writable.write(ansi.moveCursor(this.absTop, this.absLeft))

    writable.write(this.getValue() ? '[X] ' : '[.] ')
    writable.write(this.labelText)
    writable.write(' '.repeat(this.w - (this.labelText.length + 4)))

    writable.write(ansi.resetAttributes())
  }
}

class InteractiveGrouplikeItemElement extends BasicGrouplikeItemElement {
  constructor(item, app) {
    super(item.name)
    this.item = item
    this.app = app
    this.hideMetadata = false

    /*
    this.expandLabels = false
    this.labelsSelected = false
    this.selectedLabelIndex = 0
    */
  }

  drawTo(writable) {
    this.rightText = ''
    if (!this.hideMetadata) {
      const metadata = this.app.backend.getMetadataFor(this.item)
      if (metadata) {
        const durationString = getTimeStringsFromSec(0, metadata.duration).duration
        this.rightText = ` (${durationString}) `
      }
    }

    super.drawTo(writable)
  }

  selected() {
    this.computeText()
  }

  /*
  unselected() {
    this.unselectLabels()
  }

  getLabelTexts() {
    const separator = this.isSelected ? '' : ''
    let labels = []
    // let labels = ['Voice', 'Woof']
    if (this.expandLabels && this.labelsSelected) {
      labels = ['+', ...labels]
    }
    return labels.map((label, i) => {
      return [
        label,
        separator + (this.expandLabels
          ? (this.labelsSelected && i === this.selectedLabelIndex
            ? `<${label}>`
            : ` ${label} `)
          : label[0])
      ]
    })
  }

  getLabelColor(label) {
    if (label === '+') {
      return ansi.C_BLACK
    } else {
      return 30 + (label.charCodeAt(0) % 7)
    }
  }

  getFormattedRightText() {
    const labelTexts = this.getLabelTexts()

    if (labelTexts.length) {
      const lastColor = this.getLabelColor(labelTexts[labelTexts.length - 1][0])
      return (this.isSelected ? ' ' : '') +
        ansi.resetAttributes() +
        (this.isSelected ? '' : ' ') +
        ansi.setAttributes(this.isSelected ? [ansi.A_BRIGHT, 7] : []) +
        labelTexts.map(([ label, labelText ], i, arr) => {
          let text = ''

          if (this.isSelected) {
            text += ansi.setBackground(this.getLabelColor(label))
          } else {
            text += ansi.setForeground(this.getLabelColor(label))
          }

          text += labelText[0]

          // text += ansi.resetAttributes()
          text += ansi.setForeground(ansi.C_RESET)
          text += ansi.setBackground(this.getLabelColor(label))

          text += labelText.slice(1)

          return text
        }).join('') +
        ansi.setAttributes([ansi.A_RESET, this.isSelected ? 0 : lastColor]) +
        '▎' +
        ansi.resetAttributes() +
        super.getFormattedRightText()
    } else {
      return super.getFormattedRightText()
    }
  }

  getRightTextColumns() {
    const labelTexts = this.getLabelTexts()

    return labelTexts
      .reduce((acc, [l, lt]) => acc + lt.length, 0) +
      (labelTexts.length ? 2 : 0) +
      super.getRightTextColumns()
  }

  getMinLeftTextColumns() {
    return this.expandLabels ? 0 : super.getMinLeftTextColumns()
  }
  */

  getLeftPadding() {
    return 3
  }

  /*
  getSelfSelected() {
    return !this.labelsSelected && super.getSelfSelected()
  }
  */

  keyPressed(keyBuf) {
    /*
    if (this.labelsSelected) {
      if (input.isRight(keyBuf)) {
        this.selectNextLabel()
      } else if (input.isLeft(keyBuf)) {
        this.selectPreviousLabel()
      } else if (telc.isEscape(keyBuf) || input.isFocusLabels(keyBuf)) {
        this.unselectLabels()
        return false
      }
    } else */ if (input.isDownload(keyBuf)) {
      this.emit('download')
    } else if (input.isQueueAfterSelectedTrack(keyBuf)) {
      this.emit('queue', {where: 'next-selected'})
    } else if (input.isOpenThroughSystem(keyBuf)) {
      this.emit('open')
    } else if (telc.isEnter(keyBuf)) {
      if (isGroup(this.item)) {
        this.emit('browse')
      } else if (this.app.hasTimestampsFile(this.item)) {
        this.emit('toggle-timestamps')
      } else if (isTrack(this.item)) {
        this.emit('queue', {where: 'next', play: true})
      } else if (!this.isPlayable) {
        this.emit('open')
      }
    } else if (input.isRemove(keyBuf)) {
      this.emit('remove')
    } else if (input.isMenu(keyBuf)) {
      this.emit('menu', this)
      /*
    } else if (input.isFocusTextEditor(keyBuf)) {
      this.emit('edit-notes')
    } else if (input.isFocusLabels(keyBuf)) {
      this.labelsSelected = true
      this.expandLabels = true
      this.selectedLabelIndex = 0
      */
    }
  }

  /*
  unselectLabels() {
    this.labelsSelected = false
    this.emit('unselected labels')
    this.computeText()
  }

  selectNextLabel() {
    this.selectedLabelIndex++
    if (this.selectedLabelIndex >= this.getLabelTexts().length) {
      this.selectedLabelIndex = 0
    }
    this.computeText()
  }

  selectPreviousLabel() {
    this.selectedLabelIndex--
    if (this.selectedLabelIndex < 0) {
      this.selectedLabelIndex = this.getLabelTexts().length - 1
    }
    this.computeText()
  }
  */

  clicked(button, {ctrl}) {
    if (button === 'left') {
      if (this.isSelected) {
        if (ctrl) {
          return
        }
        if (this.isGroup) {
          this.emit('browse')
        } else if (this.isTrack) {
          this.emit('queue', {where: 'next', play: true})
        } else if (!this.isPlayable) {
          this.emit('open')
        }
        return false
      } else {
        this.parent.selectInput(this)
      }
    } else if (button === 'right') {
      this.parent.selectInput(this)
      this.emit('menu', this)
      return false
    }
  }

  writeStatus(writable) {
    const markStatus = this.app.getMarkStatus(this.item)
    const color = this.app.themeColor + 30

    if (this.isGroup) {
      // The ANSI attributes here will apply to the rest of the line, too.
      // (We don't reset the active attributes until after drawing the rest of
      // the line.)
      if (markStatus === 'marked' || markStatus === 'partial') {
        writable.write(ansi.setAttributes([color + 10]))
      } else {
        writable.write(ansi.setAttributes([color, ansi.A_BRIGHT]))
      }
    } else if (this.isTrack) {
      if (markStatus === 'marked') {
        writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
      }
    } else if (!this.isPlayable) {
      if (markStatus === 'marked') {
        writable.write(ansi.setAttributes([ansi.C_WHITE + 10, ansi.C_BLACK, ansi.A_BRIGHT]))
      } else {
        writable.write(ansi.setAttributes([ansi.A_DIM]))
      }
    }

    this.drawX += 3

    const braille = '⠈⠐⠠⠄⠂⠁'
    const brailleChar = braille[Math.floor(Date.now() / 250) % 6]

    const record = this.app.backend.getRecordFor(this.item)

    if (markStatus === 'marked') {
      writable.write('+')
    } else if (markStatus === 'partial') {
      writable.write('*')
    } else {
      writable.write(' ')
    }

    if (this.isGroup) {
      writable.write('G')
    } else if (!this.isPlayable) {
      writable.write('F')
    } else if (record.downloading) {
      writable.write(brailleChar)
    } else if (this.app.SQP.playingTrack === this.item) {
      writable.write('\u25B6')
    } else if (this.app.hasTimestampsFile(this.item)) {
      writable.write(':')
    } else {
      writable.write(' ')
    }

    writable.write(' ')
  }

  get isGroup() {
    return isGroup(this.item)
  }

  get isTrack() {
    return isTrack(this.item)
  }

  get isPlayable() {
    return isPlayable(this.item)
  }
}

class TimestampGrouplikeItemElement extends BasicGrouplikeItemElement {
  constructor(item, timestampData, tsDataArray, app) {
    super('')

    this.app = app
    this.data = timestampData
    this.tsData = tsDataArray
    this.item = item
    this.hideMetadata = false
  }

  drawTo(writable) {
    const { data, tsData } = this

    const metadata = this.app.backend.getMetadataFor(this.item)
    const last = tsData[tsData.length - 1]
    const duration = ((metadata && metadata.duration)
      || last.timestampEnd !== Infinity && last.timestampEnd
      || last.timestamp)
    const strings = getTimeStringsFromSec(data.timestamp, duration)

    this.text = (
      /*
      (trackDuration
        ? `(${strings.timeDone} - ${strings.percentDone})`
        : `(${strings.timeDone})`) +
        */
      `(${strings.timeDone})` +
      (data.comment
        ? ` ${data.comment}`
        : '')
    )

    if (!this.hideMetadata) {
      const durationString = (data.timestampEnd === Infinity
        ? 'to end'
        : getTimeStringsFromSec(0, data.timestampEnd - data.timestamp).duration)

      // Try to line up so there's one column of negative padding - the duration
      // of the timestamp(s) should start one column before the duration of the
      // actual track. This makes for a nice nested look!
      const rightPadding = ' '.repeat(duration > 3600 ? 4 : 2)
      this.rightText = ` (${durationString})` + rightPadding
    }

    super.drawTo(writable)
  }

  writeStatus(writable) {
    let parts = []

    const color = ansi.setAttributes([ansi.A_BRIGHT, 30 + this.app.themeColor])
    const reset = ansi.setAttributes([ansi.C_RESET])

    if (this.isCurrentTimestamp) {
      parts = [
        color,
        ' ',
        // reset,
        '\u25B6 ',
        // color,
        ' '
      ]
    } else {
      parts = [
        color,
        '  ',
        reset,
        ':',
        color,
        ' '
      ]
    }

    for (const part of parts) {
      writable.write(part)
    }

    this.drawX += 4
  }

  get isCurrentTimestamp() {
    const { SQP } = this.app
    return (
      SQP.playingTrack === this.item &&
      SQP.timeData &&
      SQP.timeData.curSecTotal >= this.data.timestamp &&
      SQP.timeData.curSecTotal < this.data.timestampEnd
    )
  }

  getLeftPadding() {
    return 4
  }
}

class ListingJumpElement extends Form {
  constructor() {
    super()

    this.label = new Label('Jump to: ')
    this.addChild(this.label)

    this.input = new TextInput()
    this.addInput(this.input)

    this.input.on('confirm', value => this.emit('confirm', value))
    this.input.on('change', value => this.emit('change', value))
    this.input.on('cancel', () => this.emit('cancel'))
  }

  selected() {
    this.input.value = ''
    this.input.keepCursorInRange()
    this.root.select(this.input)
  }

  fixLayout() {
    this.input.x = this.label.right
    this.input.w = this.contentW - this.input.x
  }

  keyPressed(keyBuf) {
    const val = super.keyPressed(keyBuf)
    if (typeof val !== 'undefined') {
      return val
    }

    // Don't bubble escape.
    if (telc.isEscape(keyBuf)) {
      return false
    }
  }
}

class PathElement extends ListScrollForm {
  constructor() {
    // TODO: Once we've got the horizontal scrollbar draw working, perhaps
    // enable this? Well probably not. This is more a TODO to just, well,
    // implement that horizontal scrollbar drawing anyway.
    super('horizontal', false)
    this.captureTab = false
  }

  showItem(item) {
    while (this.inputs.length) {
      this.removeInput(this.inputs[0])
    }

    if (!item) {
      return
    }

    const itemPath = getItemPath(item)
    const parentPath = itemPath.slice(0, -1)

    for (let i = 0; i < parentPath.length; i++) {
      const pathItem = parentPath[i]
      const nextItem = itemPath[i + 1]
      const isFirst = (i === 0)
      const element = new PathItemElement(pathItem, isFirst)
      element.on('select', () => this.emit('select', pathItem, nextItem))
      element.fixLayout()
      this.addInput(element)
    }

    this.curIndex = this.inputs.length - 1

    this.scrollToEnd()
    this.fixLayout()
  }
}

class PathItemElement extends FocusElement {
  constructor(item, isFirst) {
    super()

    this.item = item
    this.isFirst = isFirst

    this.arrowLabel = new Label(isFirst ? 'In: ' : ' > ')
    this.addChild(this.arrowLabel)

    this.button = new Button(item.name || '(Unnamed)')
    this.addChild(this.button)

    this.button.on('pressed', () => {
      this.emit('select')
    })
  }

  selected() {
    this.root.select(this.button)
  }

  clicked(button) {
    if (button === 'left') {
      this.emit('select')
    }
  }

  fixLayout() {
    const text = this.item.name || '(Unnamed)'

    const maxWidth = this.parent ? this.parent.contentW : Infinity
    this.arrowLabel.fixLayout()

    const maxButtonWidth = maxWidth - this.arrowLabel.w

    if (text.length > maxButtonWidth) {
      this.button.text = unic.ELLIPSIS + text.slice(-(maxButtonWidth - 1))
    } else {
      this.button.text = text
    }

    this.button.fixLayout()

    this.w = this.button.w + this.arrowLabel.w
    this.button.x = this.arrowLabel.right
    this.h = 1
  }
}

class QueueListingElement extends GrouplikeListingElement {
  getNewForm() {
    return new QueueListingForm(this.app)
  }

  keyPressed(keyBuf) {
    if (input.isShuffleQueue(keyBuf)) {
      this.emit('shuffle')
    } else if (input.isClearQueue(keyBuf)) {
      this.emit('clear')
    } else {
      return super.keyPressed(keyBuf)
    }
  }
}

class QueueListingForm extends GrouplikeListingForm {
  updateSelectedElement() {
    if (this.inputs.length) {
      super.updateSelectedElement()
    } else {
      this.emit('select main listing')
    }
  }
}

class PlaybackInfoElement extends FocusElement {
  constructor(queuePlayer, app) {
    super()

    this.queuePlayer = queuePlayer
    this.app = app

    this.displayMode = 'expanded'
    this.timeData = {}

    this.queuePlayerIndex = 0
    this.queuePlayerSelected = false

    this.progressBarLabel = new Label('')
    this.addChild(this.progressBarLabel)

    this.progressTextLabel = new Label('')
    this.addChild(this.progressTextLabel)

    this.trackNameLabel = new Label('')
    this.addChild(this.trackNameLabel)

    this.downloadLabel = new Label('')
    this.addChild(this.downloadLabel)

    this.queuePlayerIndexLabel = new Label('')
    this.addChild(this.queuePlayerIndexLabel)

    this.remainingTracksLabel = new Label('')
    this.addChild(this.remainingTracksLabel)

    this.updateTrack()
    this.updateProgress()

    this.handleQueueUpdated = this.handleQueueUpdated.bind(this)

    this.attachQueuePlayerListeners()
  }

  attachQueuePlayerListeners() {
    this.queuePlayer.on('queue updated', this.handleQueueUpdated)
  }

  removeQueuePlayerListeners() {
    this.queuePlayer.removeListener('queue updated', this.handleQueueUpdated)
  }

  handleQueueUpdated() {
    this.updateProgress()
    this.updateTrack()
  }

  fixLayout() {
    this.refreshProgressText()
    if (this.displayMode === 'expanded') {
      this.fixLayoutExpanded()
    } else if (this.displayMode === 'collapsed') {
      this.fixLayoutCollapsed()
    }
  }

  fixLayoutExpanded() {
    if (this.parent) {
      this.fillParent()
    }

    this.queuePlayerIndexLabel.visible = false
    this.remainingTracksLabel.visible = false
    this.downloadLabel.visible = true

    this.trackNameLabel.y = 0
    this.progressBarLabel.y = 1
    this.progressTextLabel.y = this.progressBarLabel.y
    this.downloadLabel.y = 2

    if (this.currentTrack) {
      const dl = this.currentTrack.downloaderArg
      let dlText = dl.slice(Math.max(dl.length - this.w + 20, 0))
      if (dlText !== dl) {
        dlText = unic.ELLIPSIS + dlText
      }
      this.downloadLabel.text = `(From: ${dlText})`
    }

    for (const el of [
      this.progressTextLabel,
      this.trackNameLabel,
      this.downloadLabel
    ]) {
      el.x = Math.round((this.w - el.w) / 2)
    }
  }

  fixLayoutCollapsed() {
    if (this.parent) {
      this.w = Math.max(30, this.parent.contentW)
    }
    this.h = 1

    this.queuePlayerIndexLabel.visible = true
    this.remainingTracksLabel.visible = true
    this.downloadLabel.visible = false

    const why = this.app.willActOnQueuePlayer(this.queuePlayer)
    const index = this.app.backend.queuePlayers.indexOf(this.queuePlayer)
    const msg = (why ? '!' : ' ') + index

    this.queuePlayerIndexLabel.text = (this.app.SQP === this.queuePlayer
      ? `<${msg}>`
      : ` ${msg} `)

    if (why === 'marked') {
      this.queuePlayerIndexLabel.textAttributes = [ansi.A_BRIGHT]
    } else {
      this.queuePlayerIndexLabel.textAttributes = []
    }

    this.queuePlayerIndexLabel.x = 1
    this.queuePlayerIndexLabel.y = 0

    this.trackNameLabel.x = this.queuePlayerIndexLabel.right + 1
    this.trackNameLabel.y = 0

    this.progressBarLabel.y = 0
    this.progressBarLabel.x = 0

    this.remainingTracksLabel.x = this.contentW - this.remainingTracksLabel.w - 1
    this.remainingTracksLabel.y = 0

    this.progressTextLabel.x = this.remainingTracksLabel.x - this.progressTextLabel.w - 1
    this.progressTextLabel.y = 0

    this.refreshTrackText(this.progressTextLabel.x - 2 - this.trackNameLabel.x)
    this.refreshProgressText()
  }

  clicked(button) {
    if (button === 'scroll-up') {
      this.emit('seek back')
    } else if (button === 'scroll-down') {
      this.emit('seek ahead')
    } else if (button === 'left') {
      if (this.displayMode === 'expanded') {
        this.emit('toggle pause')
      } else if (this.isSelected) {
        this.showMenu()
      } else {
        this.root.select(this)
      }
    }
  }

  keyPressed(keyBuf) {
    if (input.isSelect(keyBuf)) {
      this.showMenu()
      return false
    }
  }

  showMenu() {
    const fn = this.showContextMenu || this.app.showContextMenu
    fn({
      x: this.absLeft,
      y: this.absTop + 1,
      items: [
        {
          label: 'Select',
          action: () => {
            this.app.selectQueuePlayer(this.queuePlayer)
            this.parent.fixLayout()
          }
        },
        {
          label: (this.app.willActOnQueuePlayer(this.queuePlayer) === 'marked'
            ? 'Remove from multiple-player selection'
            : 'Add to multiple-player selection'),
          action: () => {
            this.app.toggleActOnQueuePlayer(this.queuePlayer)
            this.parent.fixLayout()
          }
        },
        this.app.backend.queuePlayers.length > 1 && {
          label: 'Delete',
          action: () => {
            this.app.removeQueuePlayer(this.queuePlayer)
          }
        }
      ]
    })
  }

  refreshProgressText() {
    const { player, timeData } = this.queuePlayer

    this.remainingTracksLabel.text = (this.queuePlayer.playingTrack
      ? `(+${this.queuePlayer.remainingTracks})`
      : `(${this.queuePlayer.remainingTracks})`)

    if (!timeData) {
      return
    }

    const { timeDone, duration, lenSecTotal, curSecTotal } = timeData
    this.timeData = timeData
    this.curSecTotal = curSecTotal
    this.lenSecTotal = lenSecTotal
    this.volume = player.volume
    this.isLooping = player.isLooping
    this.isPaused = player.isPaused

    if (duration) {
      this.progressBarLabel.text = '-'.repeat(Math.floor(this.w / lenSecTotal * curSecTotal))
      this.progressTextLabel.text = timeDone + ' / ' + duration
    } else {
      this.progressBarLabel.text = ''
      this.progressTextLabel.text = timeDone
    }

    if (player.isLooping) {
      this.progressTextLabel.text += ' [Looping]'
    }
    if (player.volume !== 100) {
      this.progressTextLabel.text += ` [Volume: ${Math.round(player.volume)}%]`
    }
  }

  refreshTrackText(maxNameWidth = Infinity) {
    const { playingTrack } = this.queuePlayer
    const waitingTrackData = getWaitingTrackData(this.queuePlayer)
    if (playingTrack) {
      this.currentTrack = playingTrack
      const { name } = playingTrack
      if (ansi.measureColumns(name) > maxNameWidth) {
        this.trackNameLabel.text = ansi.trimToColumns(name, maxNameWidth) + unic.ELLIPSIS
      } else {
        this.trackNameLabel.text = playingTrack.name
      }
      this.progressBarLabel.text = ''
      this.progressTextLabel.text = '(Starting..)'
      this.timeData = {}
    } else if (waitingTrackData) {
      const { name } = waitingTrackData
      this.clearInfoText()
      this.trackNameLabel.text = name
      this.progressTextLabel.text = '(Waiting to play, once found in playlist source.)'
    } else {
      this.clearInfoText()
    }
  }

  clearInfoText() {
    this.currentTrack = null
    this.progressBarLabel.text = ''
    this.progressTextLabel.text = ''
    this.trackNameLabel.text = ''
    this.downloadLabel.text = ''
    this.timeData = {}
  }

  updateProgress() {
    this.refreshProgressText()
    this.fixLayout()
  }

  updateTrack() {
    this.refreshTrackText()
    this.fixLayout()
  }

  clearInfo() {
    this.clearInfoText()
    this.fixLayout()
  }

  drawTo(writable) {
    if (this.isSelected) {
      this.progressBarLabel.textAttributes = [ansi.A_INVERT]
    } else {
      this.progressBarLabel.textAttributes = []
    }

    if (this.isSelected) {
      writable.write(ansi.invert())
      writable.write(ansi.moveCursor(this.absTop, this.absLeft))
      writable.write(' '.repeat(this.w))
    }
  }

  get curSecTotal() { return this.getDep('curSecTotal') }
  set curSecTotal(v) { this.setDep('curSecTotal', v) }
  get lenSecTotal() { return this.getDep('lenSecTotal') }
  set lenSecTotal(v) { this.setDep('lenSecTotal', v) }
  get volume() { return this.getDep('volume') }
  set volume(v) { this.setDep('volume', v) }
  get isLooping() { return this.getDep('isLooping') }
  set isLooping(v) { this.setDep('isLooping', v) }
  get isPaused() { return this.getDep('isPaused') }
  set isPaused(v) { this.setDep('isPaused', v) }
  get currentTrack() { return this.getDep('currentTrack') }
  set currentTrack(v) { this.setDep('currentTrack', v) }
}

class OpenPlaylistDialog extends Dialog {
  constructor() {
    super()

    this.label = new Label('Enter a playlist source:')
    this.pane.addChild(this.label)

    this.form = new Form()
    this.pane.addChild(this.form)

    this.input = new TextInput()
    this.form.addInput(this.input)

    this.button = new Button('Open')
    this.form.addInput(this.button)

    this.buttonNewTab = new Button('..in New Tab')
    this.form.addInput(this.buttonNewTab)

    this.button.on('pressed', () => {
      if (this.input.value) {
        this.emit('source selected', this.input.value)
      }
    })

    this.buttonNewTab.on('pressed', () => {
      if (this.input.value) {
        this.emit('source selected (new tab)', this.input.value)
      }
    })
  }

  opened() {
    this.input.setValue('')
    this.form.curIndex = 0
    this.form.updateSelectedElement()
  }

  fixLayout() {
    super.fixLayout()

    this.pane.w = Math.min(60, this.contentW)
    this.pane.h = 6
    this.pane.centerInParent()

    this.label.centerInParent()
    this.label.y = 0

    this.form.w = this.pane.contentW
    this.form.h = 2
    this.form.y = 1

    this.input.w = this.form.contentW

    this.button.centerInParent()
    this.button.y = 1

    this.buttonNewTab.centerInParent()
    this.buttonNewTab.y = 2
  }

  selected() {
    this.root.select(this.form)
  }
}

class AlertDialog extends Dialog {
  constructor() {
    super()

    this.label = new Label()
    this.pane.addChild(this.label)

    this.button = new Button('Close')
    this.button.on('pressed', () => {
      if (this.canClose) {
        this.emit('cancelled')
      }
    })
    this.pane.addChild(this.button)
  }

  selected() {
    this.root.select(this.button)
  }

  showMessage(message, canClose = true) {
    this.canClose = canClose
    this.label.text = message
    this.button.text = canClose ? 'Close' : '(Hold on...)'
    this.open()
  }

  fixLayout() {
    super.fixLayout()

    this.pane.w = Math.min(this.label.w + 4, this.contentW)
    this.pane.h = 4
    this.pane.centerInParent()

    this.label.centerInParent()
    this.label.y = 0

    this.button.fixLayout()
    this.button.centerInParent()
    this.button.y = 1
  }

  keyPressed() {
    // Don't handle the escape key.
  }
}

class Tabber extends FocusElement {
  constructor() {
    super()

    this.tabberElements = []
    this.currentElementIndex = 0

    this.listElement = new TabberList(this)
    this.addChild(this.listElement)

    this.listElement.on('select', item => this.selectTab(item))
    this.listElement.on('next tab', () => this.nextTab())
    this.listElement.on('previous tab', () => this.previousTab())
  }

  fixLayout() {
    const el = this.currentElement
    if (el) {
      // Only make space for the tab list if there's more than one tab visible.
      // (The tab list isn't shown if there's only one.)
      if (this.tabberElements.length > 1) {
        el.w = this.contentW
        el.h = this.contentH - 1
        el.x = 0
        el.y = 1
      } else {
        el.fillParent()
        el.x = 0
        el.y = 0
      }
      el.fixLayout()
    }

    if (this.tabberElements.length > 1) {
      this.listElement.visible = true
      this.listElement.w = this.contentW
      this.listElement.h = 1
      this.listElement.fixLayout()
    } else {
      this.listElement.visible = false
    }
  }

  addTab(element, index = this.currentElementIndex) {
    element.visible = false
    this.tabberElements.splice(index + 1, 0, element)
    this.addChild(element, index + 1)
    this.listElement.buildItems()
  }

  nextTab() {
    this.currentElementIndex++
    if (this.currentElementIndex >= this.tabberElements.length) {
      this.currentElementIndex = 0
    }
    this.updateVisibleElement()
  }

  previousTab() {
    this.currentElementIndex--
    if (this.currentElementIndex < 0) {
      this.currentElementIndex = this.tabberElements.length - 1
    }
    this.updateVisibleElement()
  }

  selectTab(element) {
    if (!this.tabberElements.includes(element)) {
      throw new Error('That tab does not exist! (Perhaps it was removed, somehow, or was never added?)')
    }

    this.currentElementIndex = this.tabberElements.indexOf(element)
    this.updateVisibleElement()
  }

  closeTab(element) {
    if (!this.tabberElements.includes(element)) {
      return
    }

    const index = this.tabberElements.indexOf(element)
    this.tabberElements.splice(index, 1)
    if (index <= this.currentElementIndex) {
      this.currentElementIndex--
    }

    // Deliberately update the visible element before removing the child. If we
    // remove the child first, the isSelected in updateVisibleElement will be
    // false, so the new currentElement won't actually be root.select()'ed.
    this.updateVisibleElement()
    this.removeChild(element)
    this.listElement.buildItems()
  }

  updateVisibleElement() {
    const len = this.tabberElements.length - 1
    this.currentElementIndex = Math.min(len, Math.max(0, this.currentElementIndex))

    this.tabberElements.forEach((el, i) => {
      el.visible = (i === this.currentElementIndex)
    })

    if (this.isSelected) {
      if (this.currentElement) {
        this.root.select(this.currentElement)
      } else {
        this.root.select(this)
      }
    }

    this.fixLayout()
  }

  selected() {
    if (this.currentElement) {
      this.root.select(this.currentElement)
    }
  }

  get selectable() {
    return this.currentElement && this.currentElement.selectable
  }

  get currentElement() {
    return this.tabberElements[this.currentElementIndex] || null
  }
}

class TabberList extends ListScrollForm {
  constructor(tabber) {
    super('horizontal')
    this.tabber = tabber
    this.captureTab = false
  }

  buildItems() {
    while (this.inputs.length) {
      this.removeInput(this.inputs[0])
    }

    for (const item of this.tabber.tabberElements) {
      const element = new TabberListItem(item, this.tabber)
      this.addInput(element)
      element.fixLayout()
      element.on('select', () => this.emit('select', item))
    }

    this.scrollToEnd()
    this.fixLayout()
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = 1
    this.x = 0
    this.y = 0
    this.scrollElementIntoEndOfView(this.inputs[this.curIndex])
    super.fixLayout()
  }

  drawTo() {
    let changed = false
    for (const input of this.inputs) {
      input.fixLayout()
      if (input._oldW !== input.w) {
        input._oldW = input.w
        changed = true
      }
    }
    if (changed) {
      this.fixLayout()
    }
  }

  clicked(button) {
    if (button === 'scroll-up') {
      this.emit('previous tab')
      return false
    } else if (button === 'scroll-down') {
      this.emit('next tab')
      return false
    }
  }

  // TODO: Be less hacky about these! Right now the tabber list is totally not
  // interactive.
  get curIndex() { return this.tabber.currentElementIndex }
  set curIndex(newVal) {}
}

class TabberListItem extends FocusElement {
  constructor(tab, tabber) {
    super()

    this.tab = tab
    this.tabber = tabber
  }

  fixLayout() {
    this.w = ansi.measureColumns(this.text) + 3
    this.h = 1
  }

  drawTo(writable) {
    if (this.tabber.currentElement === this.tab) {
      writable.write(ansi.setAttributes([ansi.A_BRIGHT]))
      writable.write(ansi.moveCursor(this.absTop, this.absLeft))
      writable.write('<' + this.text + '>')
      writable.write(ansi.resetAttributes())
    } else {
      writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1))
      writable.write(this.text)
    }
  }

  clicked(button) {
    if (button === 'left') {
      this.emit('select')
      return false
    }
  }

  get text() {
    return this.tab.tabberLabel || 'a(n) ' + this.tab.constructor.name
  }
}

class ContextMenu extends FocusElement {
  constructor(showContextMenu) {
    super()

    this.pane = new Pane()
    this.addChild(this.pane)

    this.form = new ListScrollForm()
    this.pane.addChild(this.form)

    this.keyboardSelector = new KeyboardSelector(this.form)

    this.visible = false

    this.showContextMenu = showContextMenu
    this.showSubmenu = this.showSubmenu.bind(this)
    this.submenu = null
  }

  show({x = 0, y = 0, pages = null, items: itemsArg = null, focusKey = null, pageNum = 0}) {
    this.reload = () => {
      const els = [this.root.selectedElement, ...this.root.selectedElement.directAncestors]
      const focusKey = Object.keys(keyElementMap).find(key => els.includes(keyElementMap[key]))
      this.close(false)
      this.show({x, y, items: itemsArg, focusKey})
    }

    this.nextPage = () => {
      if (pages.length > 1) {
        pageNum++
        if (pageNum === pages.length) {
          pageNum = 0
        }
        this.close(false)
        this.show({x, y, pages, pageNum})
      }
    }

    this.previousPage = () => {
      if (pages.length > 1) {
        pageNum--
        if (pageNum === -1) {
          pageNum = pages.length - 1
        }
        this.close(false)
        this.show({x, y, pages, pageNum})
      }
    }

    if (!pages && !itemsArg || pages && itemsArg) {
      return
    }

    if (pages) {
      if (pages.length === 0) {
        return
      }
      itemsArg = pages[pageNum]
    }

    let items = (typeof itemsArg === 'function') ? itemsArg() : itemsArg

    items = items.filter(Boolean)
    if (!items.length) {
      return
    }

    // Call refreshValue() on any items before they're shown, for items that
    // provide it. (This is handy when reusing the same input across a menu that
    // might be shown under different contexts.)
    for (const item of items) {
      const el = item.element
      if (!el) continue
      if (!el.refreshValue) continue
      el.refreshValue()
    }

    if (!this.root.selectedElement.directAncestors.includes(this)) {
      this.selectedBefore = this.root.selectedElement
    }

    this.clearItems()

    this.x = x
    this.y = y
    this.visible = true

    // This code is so that we don't show two dividers beside each other, or
    // end a menu with a divider!
    let wantDivider = false
    const addDividerIfWanted = () => {
      if (wantDivider) {
        if (!firstItem) {
          const element = new HorizontalRule()
          this.form.addInput(element)
        }
        wantDivider = false
      }
    }

    let firstItem = true

    const keyElementMap = {}

    for (const item of items.filter(Boolean)) {
      let focusEl
      if (item.element) {
        addDividerIfWanted()
        focusEl = item.element
        this.form.addInput(item.element)
        item.element.showContextMenu = this.showSubmenu
        if (item.isDefault) {
          this.root.select(item.element)
        }
        firstItem = false
      } else if (item.divider) {
        wantDivider = true
      } else {
        addDividerIfWanted()
        let label = item.label
        if (item.isPageSwitcher && pages.length > 1) {
          label = `\x1b[2m(${pageNum + 1}/${pages.length}) « \x1b[22m${label}\x1b[2m »\x1b[22m`
        }
        const button = new Button(label)
        button.keyboardIdentifier = item.keyboardIdentifier || label
        if (item.action) {
          button.on('pressed', async () => {
            this.restoreSelection()
            if (await item.action() === 'reload') {
              this.reload()
            } else {
              this.close()
            }
          })
        }
        if (item.isPageSwitcher) {
          button.on('pressed', async () => {
            this.nextPage()
          })
        }
        button.item = item
        focusEl = button
        this.form.addInput(button)
        if (item.isDefault) {
          this.root.select(button)
        }
        firstItem = false
      }
      if (item.key) {
        keyElementMap[item.key] = focusEl
      }
    }

    this.fixLayout()

    if (focusKey && keyElementMap[focusKey]) {
      this.root.select(keyElementMap[focusKey])
    } else if (!items.some(item => item.isDefault)) {
      this.form.firstInput()
    }

    this.keyboardSelector.reset()
  }

  showSubmenu(opts) {
    this.showContextMenu(Object.assign({}, opts, {
      // We need to get a reference to the submenu before it is shown, or else
      // the parent menu will be closed (from being unselected and not knowing
      // that a submenu was just opened).
      beforeShowing: menu => {
        this.submenu = menu
      }
    }))

    this.submenu.on('close', () => {
      this.submenu = null
    })
  }

  keyPressed(keyBuf) {
    if (telc.isEscape(keyBuf) || telc.isBackspace(keyBuf)) {
      this.restoreSelection()
      this.close()
      return false
    } else if (this.keyboardSelector.keyPressed(keyBuf)) {
      return false
    } else if (input.isScrollToStart(keyBuf)) {
      this.form.firstInput()
      this.form.scrollToBeginning()
    } else if (input.isScrollToEnd(keyBuf)) {
      this.form.lastInput()
    } else if (input.isLeft(keyBuf) || input.isRight(keyBuf)) {
      if (this.form.inputs[this.form.curIndex].item.isPageSwitcher) {
        if (input.isLeft(keyBuf)) {
          this.previousPage()
        } else {
          this.nextPage()
        }
        return false
      }
    } else {
      return super.keyPressed(keyBuf)
    }
  }

  unselected() {
    // Don't close if we just opened a submenu!
    const newEl = this.root.selectedElement
    if (this.submenu && newEl.directAncestors.includes(this.submenu)) {
      return
    }

    if (this.visible) {
      this.close()
    }
  }

  close(remove = true) {
    this.clearItems()
    this.visible = false
    if (remove && this.parent) {
      this.parent.removeChild(this)
      this.emit('closed')
    }
  }

  restoreSelection() {
    if (this.selectedBefore.root.select) {
      this.selectedBefore.root.select(this.selectedBefore)
    }
  }

  clearItems() {
    // Abhorrent hack - just erases children from memory. Leaves children
    // thinking they've still got a parent, though. (Necessary to avoid crazy
    // select() loops that probably explode the world... speaking of things
    // to forget, that one time when I was figuring out menus in the queue.
    // This makes them work.)
    this.form.children = this.form.children.filter(
      child => !this.form.inputs.includes(child))
    this.form.inputs = []
  }

  fixLayout() {
    // Do an initial pass to determine the width of this menu (or in particular
    // the form), which is the greatest width of all the inputs.
    let width = 10

    // Some elements resize to fill their parent (the menu)'s width. Since we
    // want to know what their *minimum* width is, we'll immediately change the
    // parent width that they see.
    this.form.w = width

    for (const input of this.form.inputs) {
      input.fixLayout()
      width = Math.max(width, input.w)
    }

    let height = Math.min(14, this.form.inputs.length)

    width += 2 // Space for the pane border
    height += 2 // Space for the pane border
    if (this.form.scrollBarShown) width++
    this.w = width
    this.h = height

    this.fitToParent()

    this.pane.fillParent()
    this.form.fillParent()
    this.form.fixLayout()

    // After everything else, do a second pass to apply the decided width
    // to every element, so that they expand to all be the same width.
    // In order to change the width of a button (which is what these elements
    // are), we need to append space characters.
    for (const input of this.form.inputs) {
      input.fixLayout()
      if (input.text) {
        const inputWidth = ansi.measureColumns(input.text)
        if (inputWidth < this.form.contentW) {
          input.text += ' '.repeat(this.form.contentW - inputWidth)
        }
      }
    }
  }

  selected() {
    this.root.select(this.form)
  }
}

class HorizontalRule extends FocusElement {
  // It's just a horizontal rule. Y'know..
  // --------------------------------------------------------------------------
  // You get the idea. :)

  get selectable() {
    // Just return false. A HorizontalRule is technically a FocusElement,
    // but that's just so that it can be used in place of other inputs
    // (e.g. in a ContextMenu).
    return false
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.h = 1
  }

  drawTo(writable) {
    // For the character we draw with, we use an ordinary dash instead of
    // an actual box-drawing horizontal line. That's so that the rule is
    // distinguishable from the edge of a Pane.
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
    writable.write('-'.repeat(this.w))
  }
}

class KeyboardSelector {
  // Class used to select things when you type out their name. Specify strings
  // used to access each element of a form in the keyboardIdentifier property.
  // (Elements without a keyboardIdentifier, or which are !selectable, will be
  // skipped.)

  constructor(form) {
    this.value = ''
    this.form = form
  }

  reset() {
    this.value = ''
  }

  keyPressed(keyBuf) {
    // Don't do anything if the input isn't a single keyboard character.
    if (keyBuf.length !== 1 || keyBuf[0] <= 31 || keyBuf[0] >= 127) {
      return
    }

    // First see if a result is found when we append the typed character to our
    // recorded input.
    const char = keyBuf.toString()
    this.value += char
    if (!KeyboardSelector.find(this.value, this.form)) {
      // If we don't find a result, replace our recorded input with the single
      // character entered, then do a search. Start from the input after the
      // current-selected one, so that we don't just end up re-selecting the
      // element that was selected before, if there's another option that would
      // match this key ahead. (This is so that you can easily type a string or
      // character over and over to navigate through options that all start
      // with the same string/character.)
      this.value = char
      return KeyboardSelector.find(this.value, this.form, this.form.curIndex + 1)
    }
    return true
  }

  static find(text, form, fromIndex = form.curIndex) {
    // Most of this code is just stolen from AppElement's code for handling
    // input from JumpElement!

    const lower = text.toLowerCase()
    const getName = inp => inp.keyboardIdentifier ? inp.keyboardIdentifier.toLowerCase().trim() : ''

    const testStartsWith = inp => getName(inp).startsWith(lower)

    const searchPastCurrentIndex = test => {
      const start = fromIndex
      const match = form.inputs.slice(start).findIndex(test)
      if (match === -1) {
        return -1
      } else {
        return start + match
      }
    }

    const allIndexes = [
      searchPastCurrentIndex(testStartsWith),
      form.inputs.findIndex(testStartsWith),
    ]

    const matchedIndex = allIndexes.find(value => value >= 0)

    if (typeof matchedIndex !== 'undefined') {
      form.selectInput(form.inputs[matchedIndex])
      return true
    } else {
      return false
    }
  }
}

class Menubar extends ListScrollForm {
  constructor(showContextMenu) {
    super('horizontal')

    this.showContextMenu = showContextMenu
    this.contextMenu = null
    this.color = 4 // blue
    this.attribute = 2 // dim

    this.keyboardSelector = new KeyboardSelector(this)
  }

  select() {
    // When the menubar is selected from the menubar's context menu, the UI
    // looks like it's "popping" a state, so don't reset the selected index to
    // the start - something we only do when we "newly" select the menubar.
    if (this.contextMenu && this.contextMenu.isSelected) {
      this.root.select(this)
    } else {
      this.selectedBefore = this.root.selectedElement
      this.firstInput()
    }

    this.keyboardSelector.reset()
  }

  keyPressed(keyBuf) {
    super.keyPressed(keyBuf)

    // Don't pause the music from the menubar!
    if (telc.isSpace(keyBuf)) {
      return false
    }

    if (this.keyboardSelector.keyPressed(keyBuf)) {
      return false
    } else if (input.isNextThemeColor(keyBuf)) {
      // For fun :)
      this.color = (this.color === 8 ? 1 : this.color + 1)
      this.emit('color', this.color)
      return false
    } else if (input.isPreviousThemeColor(keyBuf)) {
      this.color = (this.color === 1 ? 8 : this.color - 1)
      this.emit('color', this.color)
      return false
    } else if (telc.isCaselessLetter(keyBuf, 'a')) {
      this.attribute = (this.attribute % 3) + 1
      return false
    }
  }

  restoreSelection() {
    if (this.selectedBefore) {
      this.root.select(this.selectedBefore)
      this.selectedBefore = null
    }
  }

  buildItems(array) {
    for (const {text, menuItems, menuFn} of array) {
      const button = new Button(` ${text} `)

      const container = new FocusElement()
      container.addChild(button)
      button.x = 1
      container.w = button.w + 2
      container.h = 1
      container.selected = () => this.root.select(button)
      container.keyboardIdentifier = text

      button.on('pressed', () => {
        this.contextMenu = this.showContextMenu({
          x: container.absLeft, y: container.absY + 1,
          items: menuFn || menuItems
        })
        this.contextMenu.on('closed', () => {
          this.contextMenu = null
        })
      })

      this.addInput(container)
    }
  }

  fixLayout() {
    this.x = 0
    this.y = 0
    this.w = this.parent.contentW
    this.h = 1
    super.fixLayout()
  }

  drawTo(writable) {
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
    writable.write(ansi.setAttributes([this.attribute, 30 + this.color, ansi.A_INVERT, ansi.C_WHITE + 10]))
    writable.write(' '.repeat(this.w))
    writable.write(ansi.resetAttributes())
  }

  get color() { return this.getDep('color') }
  set color(v) { this.setDep('color', v) }
  get attribute() { return this.getDep('attribute') }
  set attribute(v) { this.setDep('attribute', v) }
}

class PartyBanner extends DisplayElement {
  constructor(direction) {
    super()

    this.direction = direction
  }

  drawTo(writable) {
    writable.write(ansi.moveCursor(this.absTop, this.absLeft))

    // TODO: Figure out how to connect this to the draw dependency system.
    // Currently the party banner doesn't schedule any renders itself (meaning
    // if you have nothing playing or otherwise rendering, it'll just stay
    // still).
    const timerNum = Date.now() / 2000 * this.direction
    let lastAttribute = ''
    const updateAttribute = offsetNum => {
      const attr = (Math.cos(offsetNum - timerNum) < 0 ? '\x1b[0;1m' : '\x1b[0;2m')
      if (attr === lastAttribute) {
        return ''
      } else {
        lastAttribute = attr
        return attr
      }
    }
    let str = new Array(this.w).fill('0').map((_, i) => {
      const offsetNum = i / this.w * 2 * Math.PI
      return (
        updateAttribute(offsetNum) +
        (Math.sin(offsetNum + timerNum) < 0 ? '-' : '*')
      )
    }).join('')

    writable.write(str)
    writable.write(ansi.resetAttributes())
  }
}

/*
class NotesTextEditor extends TuiTextEditor {
  constructor() {
    super()

    this.openedItem = null
  }

  keyPressed(keyBuf) {
    if (input.isDeselectTextEditor(keyBuf)) {
      this.emit('deselect')
      return false
    } else if (input.isSaveTextEditor(keyBuf)) {
      this.saveManually()
      return false
    } else {
      return super.keyPressed(keyBuf)
    }
  }

  async openItem(item, {doubleCheckItem}) {
    if (this.hasBeenEdited) {
      // Save in the background.
      this.save()
    }

    const textFile = getCorrespondingFileForItem(item, '.txt')
    if (!textFile) {
      this.openedItem = null
      return false
    }

    if (textFile === this.openedItem) {
      // This file is already open - don't do anything.
      return null
    }

    let filePath
    try {
      filePath = url.fileURLToPath(new URL(textFile.url))
    } catch (error) {
      this.openedItem = null
      return false
    }

    let buffer
    try {
      buffer = await readFile(filePath)
    } catch (error) {
      this.openedItem = null
      return false
    }

    if (!doubleCheckItem(item)) {
      return null
    }

    this.openedItem = textFile
    this.openedPath = filePath
    this.clearSourceAndLoadText(buffer.toString())
    return true
  }

  async saveManually() {
    if (!this.openedItem || !this.openedPath) {
      return
    }

    const item = this.openedItem

    if (await this.save()) {
      if (item === this.openedItem) {
        this.showStatusMessage('Saved manually.')
      }
    }
  }

  async save() {
    if (!this.openedItem || !this.openedPath) {
      return
    }

    const text = this.getSourceText()
    try {
      await writeFile(this.openedPath, text)
      this.clearEditStatus()
      return true
    } catch (error) {
      this.showStatusMessage(`Failed to save (${path.basename(this.openedPath)}: ${error.code}).`)
      return false
    }
  }
}
*/

class Log extends ListScrollForm {
  constructor() {
    super('vertical')
  }

  newLogMessage(messageInfo) {
    if (this.inputs.length === 10) {
      this.removeInput(this.inputs[0])
    }

    if (messageInfo.mayCombine) {
      // If a message is specified to "combine", it'll replace an immediately
      // previous message of the same code and sender.
      const previous = this.inputs[this.inputs.length - 1]
      if (
        previous &&
        previous.info.code === messageInfo.code &&
        previous.info.sender === messageInfo.sender
      ) {
        // If the code and sender match, just remove the previous message.
        // It'll be replaced by the one we're about to add!
        this.removeInput(previous)
      }
    }

    const logMessage = new LogMessage(messageInfo)
    this.addInput(logMessage)
    this.fixLayout()
    this.scrollToEnd()
    this.emit('log message', logMessage)
    return logMessage
  }
}

class LogMessage extends FocusElement {
  constructor(info) {
    super()

    this.info = info

    const {
      text,
      isVerbose = false
    } = info

    this.label = new LogMessageLabel(text, isVerbose)
    this.addChild(this.label)
  }

  fixLayout() {
    this.w = this.parent.contentW
    this.label.w = this.contentW
    this.h = this.label.h
  }

  clicked(button) {
    if (button === 'left') {
      this.root.select(this)
      return false
    }
  }
}

class LogMessageLabel extends WrapLabel {
  constructor(text, isVerbose = false) {
    super(text)

    this.isVerbose = isVerbose
  }

  writeTextTo(writable) {
    const w = this.w
    const lines = this.getWrappedLines()
    for (let i = 0; i < lines.length; i++) {
      const text = this.processFormatting(lines[i])
      writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
      writable.write(text)
      const width = ansi.measureColumns(text)
      if (width < w && this.textAttributes.length) {
        writable.write(ansi.setAttributes([ansi.A_RESET, ...this.textAttributes]))
        writable.write(' '.repeat(w - width))
      }
    }
  }

  set textAttributes(val) {}

  get textAttributes() {
    return [
      this.parent.isSelected ? 40 : null,
      this.isVerbose ? 2 : null
    ].filter(x => x !== null)
  }
}
